Lead iOS engineer
There are many challenges in the software development, but there is one beast that tends to screw things up much more often than the others: the problem of app’s state management and data propagation.
So what can go wrong with the state, which is simply a data intended for reading and writing?
There are always two questions arising for a developer when a new piece of state is introduced: “Where to store the state data?” and “How to notify the other entities in the app about the state updates?”. Let’s cover each one in details and see if there is a silver bullet to the problem.
We can have a local variable in a method or instance variable in the class, or a global variable accessible from everywhere - the main difference between them is the scope of the program that can read or write it.
When deciding where to store the new variable, we need to consider the main characteristic of the state - its locality, or the accessibility scope.
A general rule of thumb is to always aim for the smallest accessibility scope possible. A local variable defined within a method is preferred over a global variable, not only for avoiding unexpected data mutations from other scopes that we didn’t notice at the first glance, but also for improving the testability of the modules that use that data.
The local screen’s state, which is not shared with other screens, can safely be stored in the screen module itself. This only depends on the architecture you use for you screen module. For example, in case of Model-View-Controller this is the ViewController, for Model-View-ViewModel it would be the Model.
Things get much more complicated when we have the data meant to be transmitted or shared by multiple modules. There are two main scenarios:
We need to pass some state to a subsequent screen and hear back when it fulfills its duties and possibly get some data back.We have a state shared by the entire app. This could be any data that can be displayed by multiple screens that do not represent a parent-child form of ownership, like in the previous point. The data can be read or updated, and every party should gracefully handle the changes.
In this article, I cover both cases, and it’s logical to start from the first one. Just so you don’t get lost, the second case is fully covered down below in the section Shared state management, just keep reading.
Ok, the interaction between two subsequent screens (parent-child). In order to achieve loose coupling between the modules, we need to make sure that the data transfer we design does not introduce an unnecessary dependency by disclosing needless details about the parties passing or receiving the data. The less they know about each other - the better.
For passing data forward we have a practically standard technique - the dependency injection of the value itself or a reference to an entity with the read access to that data.
The opposite movement of the data back to the caller side is a bit more tricky, and we can naturally transition to answering the second question about the state:
In my previous articles I’ve already covered possible answers to this question, only among the standard tools we have:
There are endless amounts of ways how a developer can use any of these techniques alone or in combination with each other, not to mention the full freedom in choosing the naming for functions and parameters.
So which method to use for propagating the state changes?
Over the past few years I formed the following rules I follow when choosing the data propagation methods (you may have different preferences):
OK, we have some data that needs to be accessible by multiple modules in our app. Can we make it a global variable or a singleton and call it a day? Yes, if we’re on a few-hours hackathon and plan to throw the project away after it ends…
The mobile apps today have to deal with increasingly complicated business problems that involve not only feature-rich and responsive UI closely tied with the underlying data processing, but also complex state management for the data received from the server or created locally, cached in RAM or in database.
Every project has to determine a unified strategy for storing the data and its propagation through the system; failure to do it early inevitably leads to loss of control over the data flows, data consistency and a basic understanding of how the app functions.
Considering the common problems with the state I listed above, let me give some recommendations on how to organize the shared state in the app.
You have to make a choice whether to cache the state in multiple places or store in one. In my opinion, the state complying with the principle of Single Source of Truth is more practical, since you don’t need to worry about the outdated data - once you change the state - the old value is gone and cannot pop from somewhere else and ruin your user’s experience.
Imagine we’re developing a simple TODO list app. We store the list in the local database and also on the backend. When the app launches we show the local copy while running a request to the backend for obtaining a list possibly updated from another device.
If we don’t care much about the user experience, we can freeze the UI until the request is complete, but we go ahead and allow the user to check-uncheck the tasks from the list and modify it in every other way.
The race condition between the user editing the local copy and the networking request completing after a delay would force us to implement the logic for merging the document edits when there is a conflict.
For this purpose we can intruduce a local database wrapper and rely on it as the single source of truth when it comes to providing the up-to-date list of tasks. This way we can avoid unnecessary complication of the program.
Once you unify the place to store the state, it becomes much easier to extend it or refactor as the project evolves: if later we decide that we need to add a separate editing screen for the task, in that screen we can safely read from that wrapper and be sure it always provides the newest data, regardless of who and when updated it.
You need to put a barrier for the outer code not to be able to change the value and run away. What if there are other parties that need to know the value has changed? The responsibility of notifying the others should not lay on the caller side, because it’s very easy to forget to add the required code in every place the data is mutated.
On the other hand, if we introduce a wrapper for the mutation operation, then we can send the notifications from that wrapper, as well as perform other operations with the data if needed.
In our example of the TODO list, that wrapper can be a facade that conceals both access to the local database as well as the backend calls, leaving the client code with a neat API telling nothing about where the data originated from and providing a simple gateway for the data mutation.
This is another restriction you can implement in your app that will greatly improve the clarity and stability of the whole system.
Let’s suppose we didn’t put the backend API call behind the facade and originate the networking call directly from the main ViewController.
In this case, we would have two sources from where the data may come from - first is still functioning facade, another is the request completion callback, and we have to update the UI for both cases independently.
Back in the days, I worked on the app where the common practice was to send a Notification through NotificationCenterfor every case of data mutation. There was one notification for a single record update and another notification for the whole list update. And of course, the networking callbacks were also providing either a list of a single record - and the UI had to handle data coming from 4 sources! Can you imagine writing tests for such structure?
In real apps the data flows can quickly multiply, requiring a consistent effort from developers on updating the existing functionality, and the number of such places can grow exponentially along with the evolution of the app.
The approach with implementing the unidirectional data flow allows us to construct a unified channel for handling the data updates once and pretty much forget about its existence as we move on with the development.
All of this greatly contributes to the principle of least astonishment, making it easier for everyone on the project to quickly locate the state, all the possible conditions when it is mutated and the channels of the data distribution.
As you design your shared state management with these three concepts and decide to use Stream of values in your project, you can easily utilize binding the UI with the state, making the entire app super responsive to any state changes with clear declarative code for UI updates.
I would not recomment creating a global access point (a singleton object or a global variable) for the state, be that ReSwift’s state or anything else. A much more practical approach that would ensure the best decoupling and isolation of modules using the shared state is aforementioned dependency injection. For this purpose, you can utilize DI libraries like typhoon or inject the instance by assigning the reference directly from the entity that creates the new module. For the latter this could be AppDelegate assigning the dependencies for the RootViewController, or some kind of Builder or Router that creates new ViewController and injects the dependencies right after.
“Everything should be made as simple as possible, but no simpler.” - Albert Einstein
On one hand, we all need to aim for simplicity of the code we write, but there are shortcuts engineers should not take, and one of them is neglecting the design of solid state management in their apps.
Create your free account to unlock your custom reading experience.