The data-layer of your application (database, Redux state, etc.) should not have any assumptions about the interface.
When it does, it increases the risk of accidental complexity. As a result, each new change takes a disproportionate amount of time to implement.
I decided to focus on Redux because of three reasons:
- It's popular
- It allows me to demonstrate the problem with a minimum amount of code
- It's surprisingly common to design a Redux-state with UI in mind so that the UI-elements will have to perform less data processing
The core principle remains the same no matter what stack you use.
Imagine that you've built this simple task-manager:
and now you need to change the UI a bit:
How hard may it be?
Well, it depends. We can tell that the problem is simple, but we know nothing about how the system is organized. What if the code is so messy that we can't even touch it without risk of breaking something? What if we don't have tests? There are a lot of possible 'what ifs', and many of you might have seen the projects where adding one button takes days or even weeks.
You can see accidental complexity as a result of short-sightedness and previous mistakes which complicate all further work. Let's talk about one of the most common causes of it – UI-driven state.
Redux-applications can be a good example.
Don't get me wrong – Redux, as a technology, is outstanding. It promotes immutability, separation of concerns, atomic use-cases and unidirectional dataflow.
At the same time, it gives developers flexibility to opt-out all these principles. And this is the place when all the magic begins.
The most of Redux-applications look alike. They have a similar file structure and reasonable test coverage. They use the same middlewares and same libraries to force immutability. The developers, who work on them, use the same devtools.
But in spite of all the similarities, the fates of these apps are entirely different. Some of them survived several redesigns and seamlessly, while others were abandoned or rewritten.
My limited experience says that the way you organize the state of your application defines its overall maintainability. How to make it right?
UI-driven state? What is it?
One of the core components of any Redux application is a store. A store is the object that holds a whole state of your app, however big it is.
Just one object.
Obviously, there are several ways to organize it. Here is one example with two separate arrays for projects and tasks:
and another, with a UI-driven structure:
One look on the second object is enough to understand how the structure of the whole application looks like. Most likely, it has a side-panel with the projects and the main region with all the tasks.
Sounds good, but why not?
At first glance, the second example looks much more appealing. You can model the structure of your application as a tree, implement dumb UI components, and that's it. Easy to reason, easy to debug, easy to prototype.
Do you remember the task-manager we planned to redesign?
Let's think about how it might be implemented. The UI-driven state would look similar to this object:
All common use-cases, such as adding, removing, or marking tasks as completed will have one thing in common – they all will change the object
Let's have a close look at one scenario: adding new tasks.
What does exactly happen after we press the button "Add"?
Firstly, the UI-component dispatches an action with the type "TASK_ADD":
Then, a corresponding reducer applies this action to the current state:
And it perfectly works!
We're good developers, so we even cover our code with unit tests to make sure that it behaves as expected:
Everything looks fine ...
... until we need to change the UI.
It supposed to be a simple redesign, but adding tasks doesn't work correctly anymore: we expect all new elements to be at the bottom of the list, but they're still grouped by project, even though we don't have visible nesting:
It happens because we still have a nested structure in our state, as the tasks belong to the projects. This is how it looks when we use the same reducers:
To fix it, we'll have to change the shape of the state and all reducers which depend on it. We need to change input and output formats for all affected reducers, which implies that we'll need to rewrite their tests.
One minute ago, we had 100% test coverage, and now we effectively have no confidence in our code.
In contrast, it doesn't happen with a denormalized, UI-agnostic state:
Tasks don't belong to projects, they all are kept separately.
If the interface needs grouping, we can implement it in the UI-level by using Container Components which will map the state to the format, that UI can deal with:
Similarly, we can support the new interface, where the tasks aren't grouped by project:
The benefits of this approach are huge:
- We don't need to change any reducers
- The tests are still valid
- We can even support multiple interfaces if we need to
The last part
While it can be tempting to design your data-layer in accordance with the current version of an interface, remember that this is only the current version.
UI will change, it always does. You may want to run A/B tests or create a different interface for smartphones.
The last thing you want to do in this case is to reimplement the business and domain logic of your application with the risk of breaking it.
Cover photo by Magdalena Kula Manchee on Unsplash