React logo on a dark background

React 18 will be the next major version of the popular JavaScript component library. Now available as a release candidate, it introduces several changes to improve data fetches, performance and server-side rendering.

To take advantage of all the features, you’ll need to upgrade your project and may encounter some breaking changes. React 18 is still generally backwards compatible with older code though. You should be able to bump the release version in your package.json without facing too many immediate issues.

Concurrent Rendering

The motivation behind most of React 18’s revisions concerns something called “concurrent rendering.” This mechanism gives React a way to assemble multiple versions of your component tree simultaneously. While the details of this are only relevant to the library’s internals, the outcome is increased flexibility and enhanced performance for your app.

Concurrent rendering makes the rendering process interruptible. Whereas a render in React 17 must run to completion once it’s begun, React 18 provides a way to pause mid-way through and resume it later.

This ability means React renders are less likely to impact overall browser performance. Up until now, browser events like key presses and paints are blocked while a render’s ongoing. With concurrent rendering enabled, a key press will interrupt the render, allow the browser to handle the change, and then resume the rendering.

This results in a smoother user experience that’s less susceptible to stutter when rendering coincides with other activities. React maintains multiple branches of work; once one branch’s rendering is complete, which might occur over several distinct sessions, it gets accepted into the main branch that produces the visible UI.

Concurrent Mode vs Concurrent Rendering

Prior to React 18 attaining alpha status, this feature was referred to as “concurrent mode.” You might still see this name in older articles and documentation. The concept of concurrent rendering as a separate mode no longer exists in React 18. This makes it easier for existing applications to move to the new approach.

Concurrent rendering is fundamentally different to the existing rendering system. It has an entirely new API that replaces the familiar ReactDOM.render(). Back in the days of concurrent mode, concurrency was all-or-nothing: it was either enabled for your app, with the prospect of major breaking changes, or completely off limits. Now it’s handled more gracefully with React only applying concurrent rendering to DOM updates that actually require a concurrent feature.

The New Root API (Activating Concurrent Mode)

Existing apps upgraded to React 18 can keep using ReactDOM.render() for the foreseeable future. This will render your app without concurrency support, using the familiar renderer from v17. You’ll see a console warning that the API’s no longer supported but this can be disregarded while you upgrade.

import App from "./App.js";
import ReactDOM from "react-dom";
 
// "ReactDOM.render is no longer supported in React 18"
ReactDOM.render(<App />, document.getElementById("root"));

To remove the warning, switch to the new createRoot() API:

import {createRoot} from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);

createRoot() returns a new root object that represents a React rendering surface. You can call its render() method to render a React component to the root. The outcome of the above code is the same as the earlier ReactDOM.render() example. createRoot() is a more object-oriented interface with improved ease of use.

Roots produced by createRoot() support concurrent rendering. Upgrading to this API gives you opt-in access to the new capabilities of React 18.

The createRoot() equivalent of ReactDOM.unmountComponentAtNode() is the new unmount() method exposed on root objects. You can use this to detach your React component tree and stop rendering your app:

import App from "./App.js";
import ReactDOM from "react-dom";
 
// OLD
ReactDOM.unmountComponentAtNode(document.getElementById("root"));
 
// NEW
const root = createRoot(document.getElementById("root"));
root.unmount();

Concurrent Features

Concurrent rendering lets you use concurrent features to improve your app’s performance. Here are some of the key APIs available.

Suspense

The <Suspense> component has been around since React 16. It lets you prevent a component’s children from being rendered until a condition has been met. It’s commonly used for data fetches and asynchronous module imports.

const fetchPostHistory = id => fetch(`/users/${id}/posts`);
 
const UserCard = ({Id, Name}) => {
 
    const [postHistory] = useState(() => fetchPostHistory(Id));
 
    <div>
        <h1>{Name}</h1>
        <React.Suspense fallback="Loading...">
            <UserPostHistoryList posts={postHistory} />
            <ReportUserLink id={Id} />
        </React.Suspense>
    </div>
};

In this example, neither the UserPostHistory or ReportUserLink components will appear until the user’s post history data been fetched from the network. This already works well in many situations but the React 17 implementation has some quirks.

If you logged each component’s effects, you’d see the ReportUserLink component was rendered while the posts were still loading, even though it’s not visible at that point. Using the explanation of concurrency from earlier, it’s possible to explain why: once React started rendering the component tree, it had no way of stopping, even though a human can spot that ReportUserLink is redundant until postHistory is populated.

Suspense is more powerful in React 18. The new version is called “Concurrent Suspense”; the previous implementation is now referred to as Legacy Suspense. It solves the problem in the example above: rendering the same code with concurrency enabled will prevent the renderer reaching <ReportUserLink> while the data fetch is ongoing.

Logging each component’s effects would show that ReportUserLink only gets committed once the post history is available. React interrupts the render when it reaches UserPostHistoryList and needs to wait for the data to load. Once the network call’s complete, React resumes rendering the rest of the Suspense sub-tree.

This capability helps avoid wasteful work which your users never benefit from. It also solves several problems with Suspense where components may have run effects earlier than you expected. Finally, this solution provides an automatic guarantee that data will arrive in the order it was requested. You don’t need to worry about race conditions as rendering is interrupted while data is fetched.

Transitions

Transitions are a new concurrent-enabled feature. This API is a way of signalling to React the relative priorities of your UI updates. A “transition” is a relatively low-priority update, such as changing between major screens. Updates such as re-renders in response to keyboard input and other user interactions are considered more urgent.

Marking an update as a transition has a few effects on how React approaches its fulfillment. React will use the interruptible rendering capabilities of concurrency to pause the update if a more urgent one comes along mid-way through. This will help keep your UI responsive to user input while rendering’s ongoing, reducing stuttering and jank.

Transitions are useful in a broad range of situations: updating a notifications pane in your app’s header, managing updates to your sidebar, and changing other auxiliary functions of your UI are all good candidates. They also work well for asynchronous actions taken in response to user input, such as the classic case of a search bar which updates as the user types.

This can be hard to get right in React – without careful debouncing, it’s common to feel perceptible lag as updates caused by fetching new results temporarily block the main thread from handling keyboard input. With React 18, you can use a transition to mark those updates as low-priority work.

The startTransition() API encapsulates state updates as transitions:

import {startTransition} from "react";
 
const Component = () => {
 
    const [searchQuery, setSearchQuery] = useState("");
    const [searchResults, setSearchResults] = useState({});
 
    /**
     * State updates within the transition function are low-priority
     */
    startTransition(() => {
        setSearchResults({text: "Search Result 1"});
    });
 
};

If you want to check whether an update’s ongoing, replace plain startTransition() with the useTransition() hook. This gives you a boolean indicating whether a transition has pending work.

import {useTransition} from "react";
 
const Component = () => {
 
    const [searchQuery, setSearchQuery] = useState("");
    const [searchResults, setSearchResults] = useState({});
 
    const [isSearching, startSearchResultsTransition] = useTransition();
 
    startSearchResultsTransition(() => {
        setSearchResults({text: "Search Result 1"});
    });
 
    return (
        <div>
            <input onChange={setSearchQuery} value={searchQuery} />
            <SearchResults results={searchResults} />
            {(isSearching && "(Searching...)")}
        </div>
    );
 
};

All existing state updates are treated as regular urgent updates to maintain backwards compatibility with older code.

Deferred Values

Deferred values are another way of maintaining responsiveness during long-running updates. When a value’s deferred by the useDeferredValue() hook, React will keep showing its old value for a specified period.

const Component = () => {
    const [results, setResults] = useState([]);
    const deferredResults = useDeferredResults(results, {timeoutMs: 5000});
    return <ResultsGrid results={deferredResults} />;
};

Allowing React to keep showing the old results for five seconds avoids slowdowns by removing the need to immediately render fetched data as soon as it arrives. It’s a form of debouncing that’s integrated into React’s state management. Data may lag behind the real state by a few seconds in order to reduce the overall amount of work performed.

Better Batching

A final performance-oriented change in React 18 consists of a set of improvements to state update batching. React already tries to combine state updates in several simple situations:

const Component = () => {
 
    const [query, setQuery] = useState("");
    const [queryCount, setQueryCount] = useState("");
 
    /**
     * Two state updates, only one re-render
     */
    setQuery("demo");
    setQueryCount(queryCount + 1);
 
};

However there are several situations where this doesn’t work. Starting with React 18, batching applies to all updates, irrespective of where they come from. Updates that originate from timeouts, promises, and browser event handlers will be fully batched in the same way as code that’s directly within your component.

This change may alter how some code behaves. If you’ve got an old component that updates state multiple times in the places listed above, then checks values mid-way through, you might find they’re not what you expect in React 18. A flushSync method is available to manually force a state update to commit, letting you opt-out of batching.

const Component = () => {
 
    const [query, setQuery] = useState("");
    const [queryCount, setQueryCount] = useState("");
 
    const handleSearch = query => {
       fetch(query).then(() => {
 
            /**
             * Force commit and update the DOM
             */
            flushSync(() => setQuery(query));
 
            setQueryCount(1);
 
        });
    }
 
};

Server-Side Rendering Changes

Server-side rendering has been heavily revised. The headline new feature is support for streaming rendering, where new HTML can be streamed from the server to your React client. This lets you use Suspense components on the server-side.

As a consequence of this change, several APIs have been deprecated or reworked, including renderToNodeStream(). You should now use renderToPipeableStream() or renderToReadableStream() to deliver server-side content that’s compatible with modern streaming environments.

Client-side hydration of server-rendered content has also changed to align with the new concurrent rendering API. If you’re using server rendering and concurrent mode, replace hydrate() with hydrateRoot():

// OLD
import {hydrate} from "react-dom";
hydrate(<App />, document.getElementById("root"));
 
// NEW
import {hydrateRoot} from "react-dom/client";
hydrateRoot(document.getElementById("root"), <App />);

The effects of streaming rendering make server rendering more suitable for varied use cases. The existing implementation of server-side React requires the client to fetch and hydrate the entire application before it becomes interactive. By adding streams and Suspense, React can fetch only the bits needed for the initial render, then load additional non-essential data from the server after the app becomes interactive.

Conclusion

React 18 brings new features and performance enhancements for your applications. Capabilities like Suspense and Transitions make several types of code easier to write and less impactful on other areas of your app.

Upgrading to React 18 when it releases should be fairly pain-free in most cases. You can keep using React 17’s root API for the time being before moving to createRoot() when you’re ready to adopt concurrent rendering. If you want to start preparing your app today, you can install the latest release candidate by running npm install react@rc react-dom@rc.

Profile Photo for James Walker James Walker
James Walker is a contributor to How-To Geek DevOps. He is the founder of Heron Web, a UK-based digital agency providing bespoke software development services to SMEs. He has experience managing complete end-to-end web development workflows, using technologies including Linux, GitLab, Docker, and Kubernetes.
Read Full Bio »