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):

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:

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.


  1. I adopted the naming convention of ControlState and CommunicationState from James Nelson, "The 5 Types Of React Application State". I had more sub-states inside MainRoute 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. 

Browse the blog archive