How to avoid tricky async state manager pitfalls in React

Cover for How to avoid tricky async state manager pitfalls in React

These days, the local-first approach for building web apps is gaining more and more popularity. Most of these databases provide async APIs, and that make a big difference with our React apps. Read on to find out how to nip any potential problems in the bud.

Let’s recap the local-first approach for building web applications. When building apps this way, user data is stored locally. This means that it’s rapidly accessible, and thus, we can abstract away all the server communication and network error handling.

A core part of local-first applications are local databases. These provide APIs for our apps to read and write data, and we can also use the API to subscribe to look out for changes as well as synchronize changes over the network with the server, other users, or instances of the app in other browser tabs.

Examples include Logux, Replicache, RxDB, and WatermelonDB.

A diagram illustrates local architecture, showing the data flow from server, to database, to application.

The problem with local-first databases async APIs

These databases provide asynchronous APIs. This means that any changes made are not applied immediately, and during this time, it’s possible for stale data to be read.

Even though this time lag is very small, it makes a big difference for React. When changes and their corresponding effects happen asynchronously, many familiar patterns (like controlled text fields or transitions) will cease to functon correctly.

To illustrate the problem, let’s imagine we want to create a text field with persistency. It will be connected to a fictitious storage called MyStorage (which represents a local database) and text content will be saved in this storage upon each change.

Synchronous example

Assuming MyStorage is synchronous, the code will be very simple, and familiar to every React developer:

function PersistedTextField() {
	const [text, setText] = useMyStorage()
	return <input value={text} onChange={(event) => setText(event.target.value)} />
}

Text fields and asynchronous storage

However, If MyStorage has an asynchronous API, then the pattern above won’t work as expected: instead, every keystroke will reset the cursor position to the end of the text field. This makes it very troublesome to fix a typo in the middle of the text.

Let’s unpack why this happens.

Each keystroke has two effects:

  1. The value of the input component in the DOM (and the virtual DOM) is updated.
  2. The onChange handler is called to update storage.

Immediately after this, React performs its reconciliation cycle by comparing the value in the virtual DOM with the value in storage. Since the storage has not yet been updated, React notes the difference and rolls back the DOM value of the input to the previous state.

After the value has been saved to storage, React does the same thing again and finally applies the new value.

This happens very fast, but during this same period of time, React updates the input’s value programmatically two times. And this programmatic update has the side effect of reseting the cursor position to the end of the input.

To further clarify the contrast with the previous example, with the synchronous API, React will see the updated value in the DOM as well as the updated value in storage; this means it doesn’t have to update anything programmatically.

Further issues with asynchronous storages

Let’s look at another place where a problem caused by asynchronous API can crop up: animations during drag-and-drop sorting.

Imagine a list of items that should be sortable with drag and drop. In this case, the starting positions of the items are defined by some external state.

When an item is dragged to a new place, the dnd-library will update the item’s position in external state and move the item to a new position with an animation.

But if the external state update is asynchronous, the external state won’t be updated yet. So, when the animation starts, the old position will be used. This makes animations feel broken, and thus, effectively, they are broken.

Solutions

If we tried to summarize some commonalities from the examples above, we can see that the problem arises when a component has to switch from a user-controlled state to an external state, and the switch has a side effect (for instance, on cursor position or an animation).

Most of the time, React components (both internally-created and third-party libaries) are written to assume that an external state will already be updated after the switch. Otherwise the effects can go wrong.

There are different ways of fixing this problem. We can split them into 3 broad categories:

  1. Avoid problematic UI patterns: do not use text fields that are directly connected to any asynchronous storage, reduce number of animations and so on. That said, sometimes trading UX for code simplicity is not an option.
  2. Modify problematic components to work with asynchronous storages. It can be done one component at a time making it a practical choice to start with no adaption at all and then gradually improve UI by async-ready components. The downside is that each new type of UI can require some adjustments. Some third-party libraries may be incompatible at all.
  3. Add a centralized, in-memory proxy that will provide a synchronous API for the UI layer and sync its data with async storage in background. This is a good, systematic approach because UIs don’t need any further adjustments. However, the proxy code can be very non-trivial and hard to support. If your database of your doesn’t support sync API out of the box, this option is better utilized in big projects.

Let’s see how last two options work.

Centralized synchronous proxy state manager

This approach is very similar to an ordinary React SPA architecture with a state manager and server, but here, the server is replaced with a local database. The state manager should also have a mechanism to deal with remote updates from other clients via sync or cross-tab communication.

Implementation of the proxy may vary depending on the chosen local database and the state manager. But the general architecture will look something like this:

  1. When the application is initialized, data is loaded from an asynchronous local database into a proxy state manager.
  2. Whenever state is updated, the proxy saves it to the database.
  3. If the local database has built-in synchronization with the server or other clients, then the proxy state manager should subscribe to data changes in database, filter changes that come from outside, and apply them to in-memory state to insure consistency.
The diagram shows the dataflow with an in-memory proxy added to the local-first architectrue

A centralized proxy is difficult to write; it also adds complexity since there are now two sources of data with different APIs and non-trivial data flow. This option best fits big projects with custom synchronization or with complicated UIs that are too expensive to adopt for use with asynchronous APIs.

Adapting components to work with asynchronous APIs

Developing a centralized in-memory mirror of the data can be non-trivial to implement and challenging to support. A more practical option might be to modify the components that have issues with asynchronous APIs. Unfortunately, there is no universal pattern yet—each kind of component will need a customized approach. As an example, let’s adopt the connected text field from the problem section.

An async-ready text field

The cursor in the text fields jumps because React programmatically changes its value in its constant attempts to sync the virtual DOM value and external store values.

So, the problem can be fixed by removing this rigid connection and allowing the field to have an independent state, but also giving it a mechanism to eventually make it consistent with the value in the storage.

We can use an uncontrolled text fields pattern.

Data flow in uncontrolled text fields is different from the controlled and mirrors the data flow of the centralized proxy state manager applied to one component:

  1. Initialize the text field value from the external state when the component is mounted.
  2. Modify the external state when the text field is changed.
  3. Have some mechanism to propagate changes in external state back to the text field.
function UncontrolledTextField() {
  const [val, setVal] = useAsyncStore();
  const inputRef = useRef();

  useEffect(() => {
	  // Propagate outside changes back to the text field (if the value is modified outside of the component)
    inputRef.current.value = val;
  }, [val]);

  return (
    <div>
      <input
        ref={inputRef}
        // Use `defaultValue` instead of `value`
        defaultValue={val}
        // Modify external store when value is changed
        onChange={(e) => setVal(e.target.value)}
      />
    </div>
  );
}

You might notice that we still programmatically modify the text field’s value in effect to propagate changes from the storage to text field. If the storage is fast enough (that is, faster than a user can type) there won’t be any bad side effects on the cursor position because the assigned value is the same as the text field’s value. But occasionally, this can still mess with user input.

We can improve the code above by disabling back propagation when the text field is focused, with the following results:

  1. The component behaves like a normal controlled component when a user is not interacting with it.
  2. It switches to uncontrolled mode when a user starts editing it, allowing for quick, synchronous updates.
function TextField({
  value,
  onChange
}: {
  value: string;
  onChange: (val: string) => void;
}) {
  const inputRef = useRef<HTMLInputElement | undefined>();
  const [isFocused, setIsFocused] = useState(false);

  useEffect(() => {
    const input = inputRef.current;
    // If the text field is not focused sync its value to the external store
    if (input && !isFocused) {
      input.value = value;
    }
  }, [value, isFocused]);

  return (
    <input
      ref={inputRef}
      defaultValue={value}
      onChange={(e) => onChange(e.target.value)}
      onFocus={() => setIsFocused(true)}
      onBlur={() => setIsFocused(false)}
    />
  );
}

Syncing everything

And we’re out of time today. For the full code, check out this sandbox.

Hopefully, should you encounter these little quirks during your development journey, you’ll know exactly what to do to get everything back in time.

One more brief transmission from the great beyond: Evil Martians are here, and we’re ready. No matter if it’s frontend, product design, backend, devops or beyond, we’re here—please reach out to us now for more info! The time is now!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.