loading...
Cover image for Synchronized state

Synchronized state

gelio profile image Grzegorz Rozdzialik ・6 min read

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.

GitHub logo 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:

  1. data.length - regular state, depends only on the data
  2. pageSize - regular state, depends only on the user's preference
  3. maxPage - derived data, depends on data.length and pageSize
  4. currentPage - regular state (as the user can change it), but it should be at most maxPage

Dependencies between state

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:

  1. Verbose - each time a piece of state is updated, all state that depends on this property has to be updated too.
  2. Error-prone - it is possible to forget about updating one piece of state.
  3. Hard to maintain - when adding new pieces of state that depend on the existing state, multiple places have to be modified.
  4. Inefficient - in the code above, currentPage is always computed regardless of whether maxPage 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:

Dependencies between state

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.

GitHub logo 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.

Discussion

pic
Editor guide