The state management library front-ends are looking for is a database
State is a cross-cutting concern. Weak equivalence hinders re-rendering. Distance between code and data breeds complexity. State transitions need linear traces.
Most programmers manipulated state directly ever since we first learned to program. We think of state manipulation as being inherently tied to programming. It's not until we've experienced being completely overwhelmed by system complexity that we start to learn one major source of that complexity is having to maintain, manipulate, and keep track of state.
As outlined in Out of the Tar Pit, a large source of complexity is due to state management. The sentiment is also echoed in various pockets of different programming ecosystems trying to find a better way.
Recently, I went back and looked for blog post rants on React, and reactions they produced on HN and lobsters (listed at the end of this post), and the following summarizes some of the major sources of complexity due to state management.
State is a cross-cutting concern
A source of complexity is that application state is a cross-cutting concern in an interactive app of even the most modest complexity. It's not uncommon for multiple sub-trees of an app to require access to the same data model. While "views as a pure function of state" is a good architectural choice, it also implies the shape of the view is the same as the shape of the data model. This isn't commonly the case.
For example, in an app like Figma, the data model for a visual element is used across all sub-components, such as the hierarchy sidebar, the attribute sidebar, and the main canvas view. This means the data model of the visual component can't live in any one of the three as there wouldn't be a clean way for the state of the visual component to be communicated across sibling branches of the component tree.
The advice for such a situation is to pull the data model up to the common ancestor of all three sections, which in this case is the root component. When we do this, there are downsides.
The application state now needs to be propagated all the way down to the descendant component that displays it. That requires prop drilling, which introduces a lot of boilerplate and manual threading the deeper the descendant lives. While we can use Context to forgo prop drilling, this choice can un-intuitively affect rendering performance. For large apps, Context often needs to be split and ordered so that the outer-most Context is unlikely to change.
Weak equivalence hinders re-rendering
React simplifies developers' mental model by modeling rendering as blitting the entire component tree onto the screen on every frame. In reality, it incrementally updates the DOM by keeping track of which parts of the data model have changed, and hence which component to re-render. However, it's hard for React to know when data has changed in every case because Javascript was not built with immutability as a core tenet. It doesn't have a good definition of equivalence to enable the rendering engine to tell when two objects or two functions are the same. Hence, it has a hard time telling if attributes have changed from the last render.
To compensate, React requires developers to give it hints manually using keys in lists, useMemo, and useCallback. Unlike types in Haskell, these mechanisms aren't tools to help think through the architectural design. Rather they are just bookkeeping you need to do to satisfy a performance issue with the renderer.
In the ideal case, the whole system would be able to propagate an incremental change in the data model through to an incremental change in the DOM model without manual annotations from the developer. It can do that if the underlying runtime and language could tell when two objects or two functions were the same or not from frame to frame.
Distance between data and code breeds complexity
Beyond managing the local front-end state, a modern interactive app has to manage the remote state on the server. When data is in a different place than the code that needs to operate on it, complexity ensues. A lot of things can go wrong when we try to get data "over there" where it is stored to "over here" where the code is, and we fail most of the time and paper over the results.
Web apps are naturally distributed systems. We could mostly pretend that they weren't in multi-page apps (MPAs) because the business logic lived close enough to the data that we could pretend they were in the same place. Any transactional writes could be pushed down to the database and any views were stateless, so we could blow it away for a new application state on any action. [1] The UI state was simply the URL the user was currently visiting.
This simple architecture was easy to build and scale in a distributed manner, but it sorely lacked the responsiveness of desktop apps for end-users. In a desire to mimic this responsiveness without reloading the entire page in this distributed environment, developers moved the UI state and certain business logic [2] to the front-end under the banner of single-page apps (SPAs).
What we gained in responsiveness, we paid for in complexity. Now that business logic was on the front-end, the server data is now "over there". So we now need a way to query, fetch, store, and apply the data from "over there" to "over here" over an unreliable network. And we need to do it while retaining state on the front-end.
As is well-known in the Eight Fallacies in Distributed Systems, this is a recipe for a whole host of additional complexity. To name a few:
- Deduping multiple requests for the same data into a single request.
- Knowing when data is out-of-date or expired.
- Updating out-of-date data in the background.
- Updates that don't trample on other updates from other users.
- Retry policy on failures.
React Query and Hasura Subscriptions can help mitigate a lot of the problems here, but they don't go far enough. Either move UI state and business logic back to the server and give HTML a more expressive language to declare responsive interfaces [3], or move the working set of data to the front-end, so it can be treated as local data.
State transitions need linear traces
Redux is an implementation of the Flux-proposed architecture of one-way data binding and serialization of state transitions. This made it much easier to reason about the effect of changing state over its two-way binding predecessors.
Effectively, it's a state machine that serializes states between state transitions in a reliable way. It's certainly a simplifying way to think about state changes, and it's a pity that Javascript is such an impedance mismatch that it generates a generous amount of boilerplate.
Modeling state management as a state machine is only a step up if the developer was previously just thinking about state changes as a nested series of if-else statements. While state machines bring clarity by making impossible states impossible, a state machine expressed linearly in text (even as a DSL) is still quite verbose. Unless there is a better notation, it can't be used as a design and thinking tool without graphic visualizations. [4] So if a developer was already thinking about reducers as state machine transitions, most of the benefit is actually from the serialization of state transitions.
With a serialization (linear list) of state transitions, it's easier to trace a history of changes to the application state using messages as labels of user intent. More importantly, serialization also brings deterministic reproducibility to state changes and leaves the potential for conflict resolution when there are multiple concurrent sources of change to the front-end store.
Pointing to an Immutable Database
This isn't an exhaustive list of the troubles with state management in front-end interactive apps on the web. However, they all seem to point to the same place: an immutable database for building interactive apps on the web.
Why does the front-end need a database for state management? Many of the problems listed above are problems that databases have solved before for backend or distributed systems. To start, one advantage is that databases centralize the access points for data and are accessible anywhere from the app. This helps solve the problem that state is a cross-cutting concern for even the most modestly complex app.
Databases have two other features that help the state management problem: serialized transactional writes and replication.
Databases have long dealt with the hard problem of coordinating writes between multiple clients with transactions. Databases also keep a serialized log of these transactions to establish durability guarantees in case of catastrophic events like power loss. Querying this log would solve the original problem flux attempted to solve. It'd be a way of tracing the effect of a state change on the entire visual component tree over time.
Databases also feature replication between primary and secondary instances. This can be used to fetch new data from the server to the relevant working set for the front-end. Currently, developers manually fetch data from the server. But to do it well, developers need to account for timeouts with retries, cache invalidation for performance, deduping multiple requests and batching them, and pagination with lazy loading of data, all for efficiency. Most developers simply don't handle these common edge cases, and just leave users with infinite spinners, slow loads, or inconsistent views. It's even more challenging when data was changed on the server by another client, and we need to push the change to the browser to update its view without a manual refresh from the user. By leveraging database replication, between client and server, the front-end developer can treat data as completely local and not worry about network-induced state management from the server. [6]
But why an immutable database? An immutable database is like other databases except that once data is written, it's never changed. To "update" a piece of data, the new data is appended and versioned. For example, a table storing the current President of the United States wouldn't delete the previous president when a new president is elected. The new president would simply be appended to the series of presidents as a new version.
Keeping every version of data sounds like it would take too much space. In practice, this isn't a problem. Typically, only a small part of the data in any dataset is updated at a time. Therefore the data between versions have most of their data shared in common. Immutable databases leverage persistent data structures that rely on structural sharing that exploit the commonality between versions. [5]
It's this persistent data structure and structural sharing that makes it easier for the system to reason about equivalence. Two objects are the same if they point to the same part of the data structure storing the data. We can also tell exactly how two different versions are different due to the structural sharing in the persistent data structure. With that, we can tell a renderer exactly what has changed, and what it needs to re-render in turn.
In addition, immutability means there doesn't need to be coordination for reads, because once written, data will never change. That drastically reduces the complexity of multiple users in a system.
Lastly, an immutable database isn't enough. It needs to support incremental queries. Typical database queries assume that the data is stationary and it's the queries that need to be flexible. Incremental queries turn this around, where it's the query that's stationary, but data is streaming in over time. Incremental queries are more commonly found in real-time streaming databases and systems for big data, but they can be useful in interactive apps for the rendering pipeline.
When the application state held in the immutable database changes, an immutable query can update its result and propagate the difference all the way to the rendering engine, which in turn can efficiently tell what visual components in the tree need to change. This simplifies the state management for developers because they no longer need to give hints to the framework about which parts of the app to re-render.
To sum up:
- State is a cross-cutting concern: A database would allow a component from anywhere in the app query for application state.
- Weak equivalence hinders re-rendering: An immutable database leverage persistent data structures, which makes it easy to tell whether data has changed or not between renders.
- Distance between code and data breeds complexity: Developers can treat application state as local, rather than remote using database replication. In an immutable database data will never change out from under a read.
- State transitions need linear traces: The database can also serialize transactions and provide an immutable log for tracing, regardless of where in the application the write is coming from.
A step back
All this hullabaloo about a database on the front-end is only necessary if we decide that our web app definitely needs to move the UI state and business logic to the browser. This isn't the only architectural choice we can make. In fact, Rails Hotwire and Phoenix Liveview take a different tack. They keep most of the high-level UI state and business logic on the backend, close to the server database, and only ship small UI state and a (mostly) immutable view to the browser.
And given the amount of state management problems in the state-of-the-art front-end frameworks, you'd do well to consider hard whether you'd actually need it.
But given that you do, I'd argue that the state management library front-ends are looking for is a database.
[1] Besides the user session cookie.
[2] Business logic outside of access control.
[3] Interestingly, this is the direction Rails Hotwire and Pheonix Liveview take.
[4] State management libraries that focus on modeling the problem as a state machine are mistaken for this reason.
[5] This is how Git stores its data on disk. Blockchains also use this data storage pattern.
[6] That said, the developer may still need to fetch data through a 3rd party API whose database he doesn't control. In this instance, it seems like algebraic effects would be better suited.
Problems with State Management in React-likes
- React I Love You, but You're Bringing Me Down
- Get in Zoomer, We're saving React
- The new wave of React state management
- Why React Context is Not a "State Management" Tool (and Why It Doesn't Replace Redux)
- Past, Present, and Future of React State Management
- Why I'm not the biggest fan of Single Page Applications