Refactoring The Archive's ReSwift Underpinnings
While I was refactoring my app’s state recently, I noticed that there are virtually no dev logs about The Archive and how I manage state and events in this app. It’s my largest app so far, still the amount of reflection and behind-the-scenes info is pretty sparse.
This post outlines my recent changes to the app’s state. It’s a retelling of the past 5 weeks or so.
ReSwift: Central State and Control Flow
For The Archive, I am using ReSwift to control the flow of information. Every user interaction and every external file change on disk are handled by a service that emit a ReSwift-compatible Action
. This action is processed in a central place, the Store
. The previous app state plus the incoming action result in a new app state, which is then broadcast to all subscribers to take action. That’s the basic flow.
I am working with ReSwift for years now. I use it in TableFlip, too, for example. I really like how you end up with a state representation of the whole app (or document): every new feature you add results in a state change, triggered by an action. Action names like CreateNote
, ChangeFileEncoding
and SortResults
are very easy to understand when you encounter them in code.
Partitioning the App State of The Archive
It took a couple of days, but I am about to finish a major refactoring to The Archive that would’ve been much more painful if I had used a less orderly approach to state management. Over the last 18 months of development, I changed my mind about what actions do, how to handle some side effects, and also had to solve a bug here and there. Some initial design decisions have proven to be outdated. That’s the way software development works, of course: You iterate and change things, and then refactor to accommodate to new insights.
One such insight was related to my partitioning of the app’s state. Some folks work with a completely flat hierarchy of state; in ReSwift, that means 1 single struct
with a ton of properties. I was partitioning the state into various groups and created a hierarchy like this (lots of details omitted):
AppState
, the root state typeSettingsState
for global app settings,LicenseState
to manage the trial and license key info,PendingFileChanges
, a queue I wrote about before,AppRoute
, an enum representing if the editor is visible or the initial launch screen;.launch
is a case without sub-states, because there’s no state to manage in the launch screen;.main(MainRoute)
is a container just likeAppState
, but for the active editor;WorkspaceContents
contains the list of search results for further processing (the model),ControlState
groups UI contents like the current editor contents or search result selection (more like a view model, or “UI Input ports”),CommunicationState
contains pending requests by the user/UI, like “select note X” (the opposite ofControlState
, in a way; think “UI output port” or commands).1
Don’t Put Service Objects Into Your App State
I already rearranged some components of MainRoute
. Struggling to fix a memory leak, I changed the layout of MainRoute
. Previously, MainRoute
contained MainRouteState
(which grouped all the sub-states mentioned above) plus a ServiceObjects
reference. That was a container for classes like DirectoryMonitor
, for UI presenters, ultimately referencing NSWindowController
and NSViewController
objects, and user event handlers. I designed it this way a year ago so I could switch the AppRoute
and thus remove all service objects from memory in one swoop.
The rationale for the initial design: initializing the service components with their lifetime closely tied to the active route sounds like the route should ship with the services.
The rationale for the change: service component references don’t belong into the state. This also causes memory problems somewhere down the road in managing subscriptions.
The motivation for my initial design made sense, but is impractical. Cannot recommend.
Put Service Objects in the Layer that Provides the Services
So I extracted the ServiceComponents
part from the state completely. Instead of the state module, it now is only known to the outermost layer of the assembled app itself. I put it into a global variable at first and it didn’t cause problems, yet, so there’s no reason to change this now.
Extracting the reference to the service objects wasn’t too hard. I had to flip a couple of things onto their heads, though, because previously the state would ship with its services, which made some things easy, and now I have to obtain them separately from the global variable. Overall, it was an easy change.
Introducing “Workspace” as a Meaningful Partition of State
In the process, I simplified MainRoute
and got rid of the MainRouteState
container. New ideas popped up, and one of them was to rearrange and rename a couple of MainRoute
states. The WorkspaceContents
I mentioned above contain what is accessible in the current editor. I like the term “Workspace”. It’s a good name, because it conveys that this is not all data available, but stuff that is currently in use by the user.
That’s when I decided I wanted to give the term “Workspace” more importance. It was better than “main route”, and especially better than “main state”, which is basically one big code-smell.
Then it occurred to me that when I group most MainRoute
contents in a Workspace
sub-state, I could magically introduce multiple workspaces. In terms of the app, that would be multiple windows or tabs. I only have to identify Workspaces
, e.g. using a WorkspaceID
, and make sure all user events are routed so they affect the correct workspace only.
The changes affect the main app route only, so this is what it looks like now:
.main(MainRoute)
is still a container for the active editor window;MainRoute.workspaces: [Workspace]
contain a list of all active workspaces (only 1 at the moment),Workspace
groups everything that’s contained in an editing session,workspaceID
identifies the workspace,WorkspaceContents
still contains the list of search results for further processing (the model),ControlState
is mostly unchanged (UI input port),CommunicationState
also stays the same (UI output port).
Making Actions Workspace-Relative
This change affects how I write ReSwift.Actions
.
Think about the in-process action SelectingNote
, which ultimately displays the selected note and changes the search result list selection. It is not an app-global action anymore, but relative to a workspace.
To help me during the change, I removed the ReSwift.Action
compliance from SelectingNote
. This means I cannot send this as an action to the ReSwift.Store
for processing directly anymore. The compiler will complain, and I will have to fix all outdated usages. I am using the type system and “lean on the compiler” to avoid making mistakes. But if SelectingNote
is not an action anymore, I need to wrap it in an action so I can send the event.
Introducing WorkspaceTargeted<T>
:
/// Type marker for the actions of `WorkspaceTargeted`, to be used instead of `ReSwift.Action` so the
/// compiler complains when you try to dispatch them directly.
public protocol WorkspaceTargeting {}
public struct WorkspaceTargeted<T: WorkspaceTargeting>: ReSwift.Action {
public let wrapped: T
public let workspaceID: WorkspaceID
public init(_ action: T, workspaceID: WorkspaceID) {
self.wrapped = action
self.workspaceID = workspaceID
}
}
extension WorkspaceTargeted: CustomStringConvertible {
public var description: String {
return "\(wrapped) @ \(workspaceID)"
}
}
extension WorkspaceTargeted: Equatable where T: Equatable {
public static func ==(lhs: WorkspaceTargeted<T>, rhs: WorkspaceTargeted<T>) -> Bool {
return lhs.workspaceID == rhs.workspaceID
&& lhs.wrapped == rhs.wrapped
}
}
I now send the event by dispatching WorkspaceTargeted(SelectingNotes(...), workspaceID: targetID)
.
This made me rearrange actions, separating workspace-targeted actions from app global actions. This helps find things in the project. It’s a more useful grouping of code files than I had before.
I had to change about 40 actions to make use of this new distinction. Took a while.
In the process, I also found actions that were doing multiple things, or the wrong thing completely.
For example, when you create a note in the app, that means you create an in-memory Note
and write its contents as a file onto the disk. These two processes are marked as completed by dispatching CompletingNoteCreation
, which is affecting the workspace, and CompletingFileCreation
, which is affecting the file system.
Previously, CompletingFileCreation
contained a copy of the Note
that was created. This was a code-motivated addition: I needed to know when a particular Note
was created so I could display it automatically. Because file creation finishes after the workspace change, I decided to attach the info to the latest action in the sequence.
Last week, I was moving CreatingNote
and its CompletingNoteCreation
counterpart into the Workspace-targeted actions. The resulting Note
should be displayed in the workspace where the action originated. But the object was attached to the file creation action, not the note creation action. That was wrong, because file creation happens in an app-global queue. (PendingFileChanges
, remember?) This didn’t cause problems before, but now it wouldn’t work anymore.
I removed the Note
reference from CompletingFileCreation
and attached it to CompletingNoteCreation
. Then I figured, why is the sequence structured this way anyway? If creating a note starts the sequence of events, shouldn’t the completion event come last? If you think about nested events like layers of an onion, my previous sequence broke the layering. I changed the order of events a bit and now have a much neater start–finish pairing.
I probably wouldn’t have noticed this if I hadn’t changed the actions to become workspace-relative. And I wouldn’t have made them workspace-relative if the notion of a “Workspace” as a model term hadn’t appeared.
Again, this is how software development oftentimes works: you discover new concepts (here, a “workspace”) and suddenly the meaning of a lot of things going on in your app change.
The result is a code base that better conveys the intent of your actions, and improves the cohesion of states. It’s easier for me to decide if something should be part of a Workspace
than it was to figure out if it affects the bland MainRouteState
.
-
I adopted the naming convention of
ControlState
andCommunicationState
from James Nelson, “The 5 Types Of React Application State”. I had more sub-states insideMainRoute
before, but was able to rearrange most of them into control/communication, or UI input/output. Lesson learned: Keep it simple, and don’t use what you don’t need, until you need it. Top-down hierarchies always bit me, this included, and I always end up having to simplify things later. ↩