TL;DR
State that should be updated when other pieces of state change can be modeled using regular state + state synchronizers that run after each state change.
When using topological sorting, they prove to be easy to maintain and compose.
state-synchronizers
is a library that makes it easy to use the idea of state synchronization for various state management solutions.
Gelio / state-synchronizers
Deterministically update state based on other state
For a more in-depth explanation of synchronized state, read on.
Different types of state
Applications often use state to decide what is shown to the user and which actions are available. There can be different types of state.
Regular state
Regular state is what I will refer to as the raw state that can be changed and observed directly.
Regular state is the most common type of state. It can be the value of some input field that the user can freely fill in or the current route.
Regular state does not depend on other pieces of state.
Derived state
There are times when one piece of state depends purely on other pieces of state. This is what is known as derived state.
The example that nas5w presents in his great article on derived state is calculating whether the user is allowed into a bar based on the user's age and whether the user is an employee. This property can be derived strictly from other pieces of state, and can be saved either in the state management solution (e.g. redux
) or derived outside of it (e.g. using reselect
).
A third type of state?
What if you need regular state, that has to change according to some rules when other pieces of state change?
For example, what if in a Table
component you want to have a separate currentPage
value, but it has to be at most maxPage
, which is another piece of state, that is derived based on pageSize
and data.length
? All of the above should be available to the Table
component.
Let's analyze the type of those pieces of state:
-
data.length
- regular state, depends only on the data -
pageSize
- regular state, depends only on the user's preference -
maxPage
- derived data, depends ondata.length
andpageSize
-
currentPage
- regular state (as the user can change it), but it should be at mostmaxPage
While it is possible to model maxPage
using just derived data (e.g. using reselect
), this approach does not work for currentPage
. It has to be stored independently, as it can be changed without changing any other pieces of state.
This type of state is what I call synchronized state.
Synchronized state
Synchronized state is a type of regular state that can depend on other pieces of state.
In a sense, it can be thought of as a combination of regular and derived state.
How to synchronize (update the regular state) based on other properties after a state change?
Regular state + additional updates
One way to synchronize the state would be to add the logic that updates the synchronized property in every place that the parent property is updated.
For example, when updating the pageSize
, one could update maxPage
and currentPage
:
const onPageSizeChange = (pageSize) => {
const maxPage = calculateMaxPage(pageSize, state.data.length);
const currentPage = calculateCurrentPage(state.currentPage, maxPage);
updateState({
...state,
pageSize,
maxPage,
currentPage,
});
};
This approach has the following cons:
- Verbose - each time a piece of state is updated, all state that depends on this property has to be updated too.
- Error-prone - it is possible to forget about updating one piece of state.
- Hard to maintain - when adding new pieces of state that depend on the existing state, multiple places have to be modified.
- Inefficient - in the code above,
currentPage
is always computed regardless of whethermaxPage
changed (maxPage !== state.maxPage
). This could lead to unnecessary operations.
Let's explore other options that solve the problems listed above.
State synchronizer
Instead of updating each piece of state individually, let's have a single state synchronizer function that would:
- update the synchronized state
- only update the state for which at least 1 parent changed
Such a state synchronizer could look as follows:
let previousState = {};
const synchronizeState = (state) => {
if (state.data.length !== previousState.data.length || state.pageSize !== previousState.pageSize) {
state.maxPage = calculateMaxPage(state.pageSize, state.data.length);
}
if (state.maxPage !== previousState.maxPage) {
state.currentPage = calculateCurrentPage(state.currentPage, maxPage);
}
previousState = state;
return state;
}
Then, when a piece of state is updated, before the update is saved, it should be passed to synchronizeState
:
const onPageSizeChange = (pageSize) => {
updateState(synchronizeState({
...state,
pageSize,
}));
};
Further decomposition
When looking at the synchronizeState
function above, one can notice that the function can be composed out of 2 individual state synchronizers - one for maxPage
and one for currentPage
.
function synchronizeMaxPage(state, previousState) {
if (
state.data.length !== previousState.data.length ||
state.pageSize !== previousState.pageSize
) {
state.maxPage = calculateMaxPage(state.pageSize, state.data.length);
}
}
function synchronizeCurrentPage(state, previousState) {
if (state.maxPage !== previousState.maxPage) {
state.currentPage = calculateCurrentPage(state.currentPage, state.maxPage);
}
}
Given these structure, the main synchronizeState
function could be written as:
let previousState = {};
const synchronizeState = (state) => {
synchronizeMaxPage(state, previousState);
synchronizeCurrentPage(state, previousState);
previousState = state;
return state;
}
This approach scales easily to many state synchronizers. They will update the state only when necessary. There is a single function that can be invoked to apply all state synchronizations, so most of the goals set for the solution are met.
The only problem that remains is...
Order of state synchronizers
One can misplace the lines and run synchronizeCurrentPage
before synchronizeMaxPage
, causing a bug - synchronizeCurrentPage
would be using the possibly desynchronized maxPage
variable, causing errors:
const initialState: AppState = {
data: [1, 2, 3, 4],
maxPage: 2,
pageSize: 2,
currentPage: 1,
};
synchronizeState(initialState);
const finalState = synchronizeState({
...initialState,
pageSize: 4,
currentPage: 2,
});
console.log(finalState);
The log on the last line will be:
{
currentPage: 2,
data: [1, 2, 3, 4],
maxPage: 1,
pageSize: 4,
}
currentPage
is 2 even though maxPage
is 1. The synchronizeCurrentPage
ran first and used the maxPage
from the previous state, which was not synchronized yet.
As you can see, the order of state synchronizers matters. For a few variables that can be easy to comprehend, but still some burden to maintain.
Fortunately, this problem can be easily solved by using one of algorithms of computer science - the topological sorting.
State as a graph
Dependencies between the state of the application can be thought of as a directed acyclic graph.
Directed means that links in the graph are unidirectional (child state depends on parent state).
Acyclic means there are no cycles (loops) in the graph. A cycle in the dependency graph would mean that state A depends on state B, state B depends on state C, and state C depends on state A. This scenario does not make sense, as then updates would never stop.
An example dependency graph is presented below:
Topological sorting can determine the order in which state should be synchronized. First, run any synchronizers for state without parents (data.length
and pageSize
, in arbitrary order). Then, run synchronizers only for those pieces of state, for which parents have already been synchronized. This means first running the synchronizer for maxPage
, as both its parents have been synchronized, and synchronizing currentPage
as the last item.
This order matches our correct order in the hardcoded version of synchronizeState
.
state-synchronizers
state-synchronizers
is a library that makes it easy to apply the idea of synchronizing the state in your application.
Gelio / state-synchronizers
Deterministically update state based on other state
The library exposes tools for:
- easily creating state synchronizers from plain JS objects
- composing state synchronizers to run in a deterministic valid order
- applying the state synchronization pattern to existing functions (e.g. redux's reducers)
- synchronizing any type of state, not only plain JS objects (e.g. synchronizing Immutable data structures)
Take a look at the repository's README for more information.
To check the usage, take a look at the CodeSandbox below. It synchronizes the state of pagination that was explored in this post.
Summary
State that should be updated when other pieces of state change can be modeled using regular state + state synchronizers that run after each state change.
When using topological sorting, they prove to be easy to maintain and compose.
state-synchronizers
is a library that makes it easy to use the idea of state synchronization for various state management solutions.
Top comments (0)