Concurrency in React 18 for busy bees

Concurrency in React 18 for busy bees

Published at 10 November 2023

Concurrency is one of the big things we got with the release of React 18. As this feature is completely opt-in and React 18 is backward-compatible with previous versions, you might not even notice the new features. If you'd like to catch up and finally get to know what concurrency is, how it works and how it could improve your app — continue reading.

Table of contents

What is concurrency

Concurrency is a model of execution where different parts of a program can be executed out of order without affecting the end result. There are different flavors of concurrency, you might have heard about multithreading or multiprocessing. Since JavaScript in the browser has access only to one thread (workers run in a separate thread, but they aren't really related to React), we can't use multithreading to parallel some of our computations. To ensure optimal use of resources and responsiveness of the page, JS must resort to a different concurrency model: cooperative multitasking. That might sound overly complex, but fear not, you are already familiar with this model and certainly have used it.

What I'm talking about are Promise-s and async/await. Cooperative multitasking means that the program is written in such a way that some parts might be paused while they are waiting for some event and resumed later. For example, to wait for a response to your fetch you use await. This tells the browser that it can pause the current function while the response isn't there and do some other work. When the response arrives, the browser will wait while the active function yields (using await) and then resume our previous function, which now can handle the HTTP response.

How it relates to React

Before React 18, all updates in React were synchronous. If React starts processing an update, it will finish it no matter what (unless you close the tab, of course). Even if it means ignoring user events that happen at this time or freezing the page if you have particularly heavy components. It was fine for smaller updates, but for updates that involve rendering a lot of components (like route changes), it negatively impacted the user experience.

React 18 introduced two types of updates: urgent state updates and transition state updates. By default, all state updates are urgent, such updates can't be interrupted. Transitions are low-priority updates and can be interrupted. I'll also use 'high-priority update' and 'low-priority update' for those from now on.

To preserve backward compatibility, by default, React 18 behaves the same as previous versions, all updates are high-priority and thus uninterruptible. To opt into concurrent rendering, you need to mark updates as low-priority by using startTransition or useDeferredValue.

How interruption and switching works

While rendering low-priority updates, after rendering each component, React will pause and check if there is a high-priority update to handle. If there is one, React will pause current rendering and switch to rendering high-priority updates. After handling that, React will return to rendering a low-priority update (or discard it if it's not relevant anymore). Besides high-priority updates, React will also check that the current render doesn't take too much time. If it does, React will yield back to the browser, so it can repaint the screen to avoid lagging and freezing.

Since React can yield only between rendering components (it can't stop in the middle of a component), concurrent rendering won't help much if you have one or two heavy components. If component render takes 300 ms, the browser will be blocked for 300 ms. Where concurrent rendering really shines is when your components are only slightly slow, but there are so many of them that they add up to quite a long total rendering time.

What about Suspense?

You might have heard about CPU-bound programs. Such programs, most of the time, actively use the CPU to do their work. Slow components, which we mentioned earlier, can be categorized as CPU-bound: to render quicker, they need more resources.

Contrary to CPU-bound programs, there are IO-bound programs. Such programs spend most of their time interacting with input-output devices (e.g., disks or network). The component responsible for handling IO (which comes mostly in the form of network requests) in React is Suspense. I covered how it works in this guide, you might want to check it out to better understand what we're talking about.

Suspense behaves a bit differently if a component suspends during a low-priority update. If there is already content displaying inside the Suspense boundary, rather than handling suspension as usual and showing fallback, React will pause rendering and switch to other tasks until the promise resolves, and then commit a complete subtree with new content. This way, React avoids hiding already-present content. If a component suspends during the first render, fallback will be shown.

How to initiate transition

There are a few ways to initiate transition, the most basic of them is startTransition function. You use it like this:

import { startTransition, useState } from 'react';
 
const StartTransitionUsage = () => {
    const onInputChange = (value: string) => {
        setInputValue(value);
        startTransition(() => {
            setSearchQuery(value);
        });
    };
 
    const [inputValue, setInputValue] = useState('');
    const [searchQuery, setSearchQuery] = useState('');
 
    return (<div>
        <SectionHeader title="Movies" />
        <input
            placeholder="Search"
            value={inputValue}
            onChange={(e) => onInputChange(e.target.value)}
        />
        <MoviesCatalog searchQuery={searchQuery} />
    </div>);
};

What happens here is that when the user types into a search input, we update state variable inputValue as usual, and then call startTransition where we pass a function with another state update. This function will be called immediately, and React will record any state changes made during its execution and mark them as low-priority updates. Please note that only synchronous functions should be passed to startTransition (at least as of React 18.2).

So in our example, we actually initiate two updates: one urgent (to update inputValue) and one transition (to update searchQuery). MoviesCatalog component might use Suspense to fetch movies by search query, which will make this component IO-bound. Furthermore, it can render quite a long list of movie cards, which might make it CPU-bound too. With transition, this component won't trigger Suspense fallback while loading data (stale UI will be displayed) and won't freeze our browser while rendering a long list of cards.

It's important to note that in the case of CPU-bound components, they should be wrapped with React.memo, otherwise they will be re-rendered at every high-priority render, even if their props didn't change, and this will take a toll on the performance of your app.

startTransition is the most basic function and is intended for use outside of the React component. To initiate transition from the React component, we have a cooler version of it: the useTransition hook.

import { useTransition, useState } from 'react';
 
const UseTransitionUsage = () => {
    const onInputChange = (value: string) => {
        setInputValue(value);
        startTransition(() => {
            setSearchQuery(value);
        });
    };
    const [inputValue, setInputValue] = useState('');
    const [searchQuery, setSearchQuery] = useState('');
    const [isPending, startTransition] = useTransition();
 
    return (<div>
        <SectionHeader title="Movies" isLoading={isPending} />
        <input
            placeholder="Search"
            value={inputValue}
            onChange={(e) => onInputChange(e.target.value)}
        />
        <MoviesCatalog searchQuery={searchQuery} />
    </div>)
};

With this hook, you don't import startTransition directly; instead, you call the useTransition() hook, which returns an array with two elements: a boolean indicating if there is any low-priority update in progress (initiated from this component) and startTransition function you use to initiate transition.

When you initiate a transition this way, React will actually do 2 renders: one high-priority render to flip isPending to true and a low-priority update with actual state changes you passed to startTransition. So be cautious and wrap expensive components with React.memo.

Another new hook we got is useDeferredValue. It becomes useful if the same state is used in both critical and heavy components. Just like in our example above. How convenient, huh? This is how you use it:

import { useDeferredValue, useState } from 'react';
 
const UseDeferredValueUsage = () => {
    const [inputValue, setInputValue] = useState('');
    const searchQuery = useDeferredValue(inputValue);
 
    return (<div>
        <SectionHeader title="Movies" isLoading={inputValue !== searchQuery} />
        <input
            placeholder="Search"
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
        />
        <MoviesCatalog searchQuery={searchQuery} />
    </div>);
};

In low priority renders and on the first render in high priority, useDeferredValue will store the passed value and return it right away, so inputValue and searchQuery will be the same string. But on subsequent high-priority renders, React will always return the stored value. But it will also compare the value you passed with the stored value, and if they are different, React will schedule a low-priority update. If the value changes once again during a high-priority update while a low-priority update is in progress, React will discard it and schedule a new low-priority update with the latest value.

Using this hook, you can have two versions of the same state: one for critical components like input field (where lagging is usually unacceptable) and the other for components like search results (where the user is accustomed to longer delays).

Closing thoughts

Concurrency is for sure an interesting feature, and I'm certain some complex apps will benefit from it. Even more, it's probably already used under the hood of your favorite React framework (both Remix and Next use it for routing). And I suspect it will be even more appreciated once Suspense for data fetching hits production-ready status. But for now, you still have time to learn and gradually adopt it into your apps. If you'd like to dive into concurrency in React with a bit more detail and historical context, check out this talk by Ivan Akulov, it's good. See you!

Published at 10 November 2023

Yoy may also like: