How I Avoided Over-Architecting a Simple Event Handler with ReSwift, Cheating with Existing Reactive Code
I am using ReSwift in my app. I like ReSwift because I can create a representation of my app in a “State” Swift module without any UIKit/AppKit dependency. There, I put all the data that the app is supposed to represent at any given moment in time.
To get a feeling for this, have a look at two apps of mine:
In case of my app TableFlip, this means, simplified: the two-dimensional tabular data of all tables in the current document; a pointer to the currently visible table (i.e., which tab is active); and the user selection measured in rows and columns.
In my more recent project, The Archive, the state includes: user preferences, including the path to the directory of notes; a list of all files that match the current search, to be shown in the list to the list; the text of the currently displayed note; the contents of the Omnibar (the search field at the top); temporary pending file changes, like saving, if any.
You see, part of the app state is a representation of what the user is supposed to be looking at. Not every static label description, but the changing parts.
To change the state, you dispatch actions. I like to phrase them in effect form: CreatingNote
, ChangingArchiveDirectory
, AddingTableRow
, etc. The ReSwift framework expects you to write reducers that take in actions and the current state and update the state accordingly. It’s pretty simple input–output, really. In the end, your app fires these actions and your state subscribers receive a state update.
This is all fine and dandy. But sometimes I catch myself adding actions only to enqueue side-effects. I wrote about enqueuing file changes last year. I’m still not unhappy with that approach, but I am having doubts now. In essence, I figured that storing a string to a URL is part of the backstage state of the app. “The app is saving a file” sounds like a good sub-state description to me.
Why do I doubt that now?
Yesterday, I added a pretty simple functionality. The Archive is a macOS app, so it has a main menu. From the main menu, you should be able to insert a date-time string like 201803041008
for “2018-03-04 10:08”, which is supposed to be an identifier for a note. This would be a simple UI helper concern to set up and use a DateFormatter
. But I want to avoid duplicates. You get 1 ID per minute with this setting. To keep them unique, the app should figure out if you used the ID to permanently identify a note already. If you are too quick and want to assign multiple IDs to notes in one minute, the app should take care of giving you a different, unused number. This requires knowledge about the state, though: which ID is free?
I do have services outside the UI layer that take care of this. They determine if an ID is taken in the archive of notes, and I can obtain a fixed ID in return. Easy.
But how should I wire the menu item to this service and then insert it at the current cursor position in the editor’s NSTextView
? The UI layer is not allowed to reach outside its confines.
The way my app architecture is handles all user events is through ReSwift actions. The UI, treated as a I/O black box, exposes a list of events an event handler subscribes to. Like the existing “Save Note Changes” and “Select Note”, this new event would be called “Request Identifier”. Only this time, it doesn’t affect the state at all. Requesting an identifier is a process that depends on the state to figure out which ID is free, but it does not modify the state. Its only effect is “Typing ‘201803041008’ Into The Text Field”. This effect is not a great candidate for ReSwift states, though. I do not route key presses from the user through state updates. They remain enclosed in the UI layer. Only periodically do changes to the text get propagated. I didn’t want to write a terminal emulator, I want to use the Cocoa framework to build an app, so this still sounds reasonable to me. To enqueue typing an unused ID into the text field in the state makes no sense.
Still, I was tempted. After all, the state module is the trustworthy, functional, well-tested core of the app’s operation. This is an event that takes place. Why not put the request and its result into the state like all the other events?
Keep in mind that both request and response need to be put into the ReSwift state if you use the state object as your message bus: store the user-originated event as a request object (RequestingTypingID
), subscribe to these somewhere, send back a completion to delete the pending request (CompletingIDRequest
), and send a the result that’s supposed to be typed into the text field (TypingText("201803041008")
) which another subscriber processes, only this time it’s operating like a presenter and knows the UI. Mixing both subscribers and both processes into one would work but that’s not good factoring.
Instead of all this, I realized I have another event queue at my disposal apart from ReSwift: RxSwift. I use this reactive library to wire components together and use it a lot inside the UI (or “Presentation”) layer. There’s a lot less overhead in exposing event streams with RxSwift, as it is designed exactly for this purpose, while ReSwift is more concerned with the state representation through events.
The benefit of event queues, both through the monolithic state update of ReSwift or the individual observable sequences of Rx, is decoupling.
That’s the basic proposition of the Observer pattern. It’s what makes NSNotification
so useful sometimes. I want to emit an event from the menu item (Presentation layer) to be processed by a service object with access to knowledge about existing identifiers (Application layer) so that the result gets typed into the editor. Since I do not inject delegates or other event handlers into the UI in this app but instead expose observable streams, continuing on this path was the natural choice:
- The UI exposes
requestIdentifier: Observable<Void>
; this could work the same way but non-reactively using a delegate. Note it doesn’t pass any info around, it’s just a signal. - I inject something like a
GenerateIdentifier
service into my wireframe object. That’s the thing that connects presenters to the UI, and the UI to action dispatchers. ThisGenerateIdentifier
is just a new dependency it expects. - The wireframe thingie subscribes
GenerateIdentifier.generateID()
, which returns a string, torequestIdentifier
events. Now each request is transformed into a string, if the request didn’t produce errors, that is. - I can consume these and forward them to the UI through the
TypesText
protocol. It is implemented, in shortened form, as:(window?.firstResponder as? NSText)?.insertText(string)
.
What did I do here?
- I maintained the strict separation of UI and knowledge about the note repository through an Application Service:
GenerateIdentifier
. - I extended the wireframe to perform another wiring of action and response, building on top of the existing set-up.
- I did not use ReSwift as a general purpose message bus.
This case made me re-think my approach to saving note changes, too. Please note that I don’t say file changes, because a file is not part of my application’s problem domain. It’s only the final representation of the data through the persistence layer. One thing I do find useful, though, is that I can configure my ReSwift setup to react to note changes with more side-effects, like updating the displayed contents. I have to think about the cost and payoff some more.
I’m happy that I did figure out how to not increase the core State module’s capabilities for such a mundane task. It’s a mere side-effect that’s not affecting the state at all. In my book, it’s good to keep the core small and push all the bloat out into the less pure layers of the app. They’re easier to replace and modify since they don’t affect other modules.
I’m also happy with this particular solution because I avoided over-engineering a simple UI event as a series of actions and transient state updates.
I’m not happy about the diversion in event handling processes, though. This is a precedence for future code to look the same. Will I avoid dispatching ReSwift actions because of the overhead and because, well, there’s this one thing that doesn’t use it, either? Probably. This indicates I have to re-think this pretty old core concept of my app. It’ll be time for a serious refactoring, now that I know better how to approach the problem the app is trying to solve.