Back

React 18: Features Breakdown

React 18: Features Breakdown

React 18 has been officially released, bringing many exciting, new features. In this release, React embraces concurrency with its improved rendering system and builds upon this foundation with performance-enhancing features like transitions or automatic batching.

In this article, you’ll learn how these features work and what they mean for you as a React developer.

Road to the official release

Before diving into all the features, let’s revisit the entire process behind the release of React 18, as it was pretty unique compared to prior versions. React 17 didn’t bring many new features. It did, however, lay the groundwork for future updates by improving on the fundamentals and enabling seamless gradual adoption of new React features. The effect of those changes is only now visible with React 18.

The most significant change in the release process of React was a newly-created React Working Group (WG) announced alongside the official alpha release. The group’s goal is to gather feedback from the community and prepare the ecosystem for the upcoming changes. Additionally, it serves as a great source of knowledge about React’s inner workings.

Thanks to the improvements from React 17 and input from the Working Group, React 18 turned out to be a feature-packed release, with only a handful of breaking changes.

Breaking changes

With new concurrent features being gradually adaptable and enabled on-demand, the breaking changes in React 18 were limited to simple API changes and some improvements to the stability and consistency of different behaviors across React.

Client rendering APIs

One of the most noticeable changes is the new root API with createRoot(). It’s meant to replace the existing render() function, providing better ergonomics and enabling new concurrent rendering features.

import { createRoot } from "react-dom/client";
import App from "App";

const container = document.getElementById("app");
const root = createRoot(container);
root.render(<App />);

Notice that the new API is now exported from the react-dom/client module.

Also changed are the unmounting and hydration APIs.

// Unmount component at DOM node:
// ...
root.unmount();

// Hydration
import { hydrateRoot } from "react-dom/client";
// ...

const container = document.getElementById("app");
const root = hydrateRoot(container, <App tab="home" />);

Gone is the render callback due to issues in timing it correctly with Suspense. An alternative, although not a one-to-one replacement, is an effect inside the top component:

import { createRoot } from "react-dom/client";
import { useEffect } from "react";
import App from "App";

const App = () => {
  useEffect(() => {
    console.log("render callback");
  });

  return <div></div>;
};
const container = document.getElementById("app");
const root = createRoot(container);
root.render(<App />);

Automatic batching

The createRoot() API also serves as an entry to another improvement in React 18 - automatic batching. In prior versions of React, state updates were already batched when done in React event listeners to optimize performance and limit re-renders. Beginning with React 18, the state updates will also be grouped in other places - like inside Promises, setTimeout callbacks, and native event handlers.

const App = () => {
  const handleClick = () => {
    setA((a) => a + 1);
    setB((b) => b - 1);
    // Updates batched - single re-render
  };

  setTimeout(() => {
    setA((a) => a + 1);
    setB((b) => b - 1);
    // New (v18): Updates batched - single re-render
  }, 1000);

  // ...
};

This change, while usually desired and helpful, can be breaking. If your code depends on the component re-rendering in-between separate state updates, you’ll have to adapt it to the new batching mechanism or use the flushSync() function to force an immediate flush of changes.

import { flushSync } from "react-dom";
// ...

const handleClick = () => {
  flushSync(() => {
    setA((a) => a + 1);
  });
  // Re-render
  flushSync(() => {
    setB((b) => b - 1);
  });
  // Re-render
};

Strict mode updates

While React 18 is already full of new features, there’s still a lot coming. To get your code ready for that, StrictMode is getting even more strict. Most importantly, StrictMode will test the component’s resiliency to a reusable state, simulating a series of mounts and unmounts. It’s meant to get your code ready for the upcoming feature (likely in the form of an <Offscreen> component), which will preserve the state across the component’s mounting cycles.

While it’ll certainly provide better performance in the future, for the time being, it’s one other thing you have to keep in mind when enabling StrictMode.

Other changes

Apart from the mentioned changes, there are a few more that you might find breaking, depending on your React codebase.

Worth mentioning is that React 18 will no longer support Internet Explorer, as React 18 now depends on modern browser features like Promise or Object.assign. Given that Microsoft will stop supporting the browser on June 15 of this year, it’s only natural that React and other JS libraries will be dropping support as well. Those who still need to support IE will have to stay on React 17.

The rest of the changes are related to the stability and consistency of different React behaviors and are unlikely to affect your codebase. With that said, you can find the complete list here.

Concurrent React

The concurrent renderer is a behind-the-scenes feature of React’s rendering system. It allows for concurrent rendering, i.e., preparing multiple versions of your UI, in the background, at the same time. This means better performance and smoother state transitions.

While concurrency might seem like an implementation detail, it’s what powers most of the new features. In fact, concurrent rendering is only enabled when you use one of those features, like transitions, Suspense, or streaming SSR. That’s why it’s essential to know about and understand how concurrent rendering works.

Transitions

One of the new features powered by concurrent rendering is transitions. Transitions are meant to be used with existing state management API to differentiate between urgent and non-urgent state updates. This way, React knows which updates to prioritize and which to prepare in the background with concurrent rendering.

To know when to use transitions, you’ll have to better understand how users interact with your app. For example, typing in a field or clicking a button are actions to which users expect an immediate response - a value to appear in a text field or some menu to be opened. However, searching, loading, or processing data (e.g., search bar, chart, filtering table, etc.) is something that the user expects will take some time. The latter is where you’d use transitions.

You can use the useTransition() hook to create a transition. The hook returns a function to start a transition and a pending indicator to inform you about the transition’s progress.

import { useTransition, useState } from "react";

const App = () => {
  const [isPending, startTransition] = useTransition();
  const [value, setValue] = useState(0);

  function handleClick() {
    startTransition(() => {
      setValue((value) => value + 1);
    });
  }

  return (
    <div>
      {isPending && <Loader />}
      <button onClick={handleClick}>{value}</button>
    </div>
  );
};

Any state updates you commit inside the startTransition() callback will be marked as transitions, thus making other updates take priority. There’s also a separate startTransition() function if you can’t use the hook, though it doesn’t inform you about the transition’s progress.

import { startTransition } from "react";
// ...
startTransition(() => {
  // Transition updates
});
// ...

Suspense updates

Transitions work best when combined with React Suspense. Thanks to some improvements, Suspense now integrates well with concurrent rendering, works on the server, and might soon support use-cases outside lazy()-loaded components. When used with transitions, Suspense will avoid hiding existing content. Consider the following example:

import { Suspense } from "react";
// ...

const App = () => {
  const [value, setValue] = useState("a");
  const handleClick = () => {
    setValue("b");
  };

  return (
    <>
      <Suspense fallback={<Loader />}>
        {value === "a" ? <A /> : <B />}
      </Suspense>
      <Button onClick={handleClick}>B</Button>
    </>
  );
};

Upon state change, lazy()-loaded component will trigger Suspense leading to the rendering of the fallback element. If you mark the state change as a transition, React will know that it should prepare the new view in the background while still keeping the current one visible.

import { Suspense, useTransition } from "react";
// ...

const App = () => {
  const [value, setValue] = useState("a");
  const [isPending, startTransition] = useTransition();
  const handleClick = () => {
    startTransition(() => {
      setValue("b");
    });
  };

  return (
    <>
      <Suspense fallback={<Loader />}>
        <div style={{ opacity: isPending ? 0.7 : 1 }}>
          {value === "a" ? <A /> : <B />}
        </div>
      </Suspense>
      <Button onClick={handleClick}>B</Button>
    </>
  );
};

Now, even though the view won’t change when the transition is processed, you can still give user feedback by using the transition indicator to, for example, set container opacity. Combining the mentioned improvements with the future ability to use Suspense with asynchronous tasks other than lazy()-loaded components means Suspense will become one of the most powerful features of React.

Server rendering improvements

Apart from Suspense support, there are a lot of other changes happening on the SSR side of React. Combining Suspense with SSR streaming and lazy hydration means your server-side rendered app will hydrate and be usable as soon as possible. Not only that, zero-bundle-size server components are coming. They’re currently experimental but might become stable in later minor releases. With them, you’ll be able to reduce the JS code that’s served to the client, even further optimizing the performance and loading times of your React app.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.

Gradual adoption

Thanks to mentioned changes from React 17, even with a large codebase, you should be able to adopt React 18 with ease, gradually. Not only can you use the new version in only selected parts of your app, but you can also opt-in to new features and behaviors gradually by migrating from render() to createRoot(). On top of that, even with createRoot(), you still get to adopt concurrent rendering step by step, as it’ll only be enabled when you use its feature. Overall, the migration process should be smooth and potentially even enjoyable.

The future of React

While React 18 brought a lot of new features, you can already see new things on the horizon. Server components, Suspense for data fetching, and <Offscreen> component rendering are just some of the upcoming features.

React is evolving with its entire ecosystem, and I can’t wait to see what comes next!