Skip to content

Getting Familiar with React 18

It’s a lovely workday morning and the Slack notification chime takes me away from my morning tea. This time it’s ‘Pineapple & Pear’, and as I hear tea sommeliers moaning at such a travesty, I see a message from a colleague:

“Hey there!

Could you check out what's new in React 18 and update the X project if possible?


I used to dabble in the fine art of frontend-craft (a distant, slightly awkward cousin of witchcraft), so that shouldn’t be an issue, I assume, and begin my journey into ‘what’s new in React 18’...

The mundane of keeping up

Every time I see a full URL containing the date stamp, I unconsciously make an assumption about how stale the data I’m about to look into is. And with technology-related articles, blog posts, and changelogs in this day and age, the period for ‘still-not-stale’ is getting shorter and shorter.

As your favourite packages are bumping their versions almost every day, it becomes a mundane ‘not staying behind’ cat and mouse game.

One could simply upgrade all the packages to the latest non-major versions (following Semantic Versioning specification , which would mean updates that do not contain incompatible API changes). But, as recently reiterated by the incidents with highly-used OSS packages, it might not be the best option for you or your client’s project.

As with everything in software development, choosing when and what to update is a balancing act between business needs and technical debt. And that’s a decision every tech team has to make and dedicate time for. Related Read: Why and How to Automate Dependency Bumps

The fright of checking

Depending on the number of external packages your project relies on, running npm outdated or yarn outdated can be a fun little task before your stand-up or a stressful day spent in changelogs. npm outdated will provide you with a list of the packages used in the project, their current version, wanted version (the version that can be automatically updated based on package ranges defined in package.json using npm update), and the latest version of the package.

An example excerpt from one of our projects was:

Plain Text
Package                 Current  Wanted  Latest  Location                             Depended by
react                    17.0.2  17.0.2  18.0.0  node_modules/react                   project X
react-dom                17.0.2  17.0.2  18.0.0  node_modules/react-dom               project X
@testing-library/react   12.1.4  12.1.4  13.0.0  node_modules/@testing-library/react  project X

An alternative tool to manage your package versions for npm packages is ncu - npm-check-updates . Yarn also offers interactive tools with the yarn interactive upgrade .

Now that the situation is clear, the real investigation can begin: what’s changed, how it will affect the project, and what changes are required. With a few exceptions, patch and minor version updates shouldn’t create any issues, however, it’s still a good idea to check what’s changed in the minor updates. It’s also important to check how new the changes are and whether these changes have introduced any issues.

The fun of catching up

As React was bumped to a major version, we can safely assume that something will need to be changed in code to support the new version.

Root API

In React, a ‘root’ element is a reference to the top-level data structure point from which the component tree is tracked and rendered. Before version 18, this element wasn’t accessible to developers as it was directly attached to the DOM element and accessed through the DOM node. Developers would get the DOM element and pass it into the render() function. Now, the root element is created first, and then the rendering can be performed from it.

React 18 ships with support for both legacy and new root APIs , which allows users to:

  • gradually update to the new API without the application crashing on update
  • more easily test the performance of both approaches.

A warning to the legacy root API has been added that recommends using the new API. And to update, we will need to start using createRoot from react-dom/client instead of render from react-dom .

Plain Text
import ReactDOM from 'react-dom'

  <App />,

is replaced with

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

Automatic batching

Automatic batching is a process React uses to automatically batch state updates in React event handlers that limits the number of renders needed to update the view to its current state. With React 18, this optimisation is extended to promises, setTimeouts, and native event handlers - and re-render happens only once where possible.

An example for autobatching is attached below. As React 18 ships with both Root APIs, you can easily switch between them and see the difference in re-rendering using React Dev Tools Profiler, as new functionality is only available with the new Root API.

Clicking the Event Handler button fires a regular event handler, and both React 18 and the previous implementation will re-render once. Clicking the Fetch or Timeout buttons would force two re-renders in the previous React version, but with React 18, setting the state is batched and re-render only happens once:

Plain Text
import React from 'react';
import { flushSync } from 'react-dom';
const App = () => {
 const [count, setCount] = React.useState(0);
 const [randomNumber, setRandomNumber] = React.useState(0);
 const handleClick = () => {
   // Before React 18 - 1 re-render
   // After React 18 - 1 re-render
   setRandomNumber(Math.random(new Date()));
   setCount(count => count + 1);
 const handleFlushClick = () => {
   // Before React 18 - 2 re-render
   // After React 18 - 2 re-render
   flushSync(() => {
     setRandomNumber(Math.random(new Date()));
   flushSync(() => {
     setCount(count => count + 1);
 const handleFetch = () => {   
   // Before React 18 - 2 re-render
   // After React 18 - 1 re-render
     .then(() => {
       setRandomNumber(Math.random(new Date()));
       setCount(count => count + 1);
 const handleTimeout = () => {
   // Before React 18 - 2 re-render
   // After React 18 - 1 re-render
   setTimeout(() => {
     setRandomNumber(Math.random(new Date()));
     setCount(count + 1);
return (
     <div>Count: {count} | Random number: {randomNumber}</div>
     <button onClick={handleClick}>Event handler</button>
     <button onClick={handleFetch}>Fetch</button>
     <button onClick={handleTimeout}>Timeout</button>
     <button onClick={handleFlushClick}>FlushSync</button>
export default App;

Example code also includes the flushSync() method, which disables automatic batching if needed. Both Root APIs will re-render twice when running the handleFlushClick() method.

Concurrent React

With React 18 comes a new way to render content in React: the concurrent render. The key property behind it is that rendering is interruptible. With previous React versions and with React 18, without any concurrent features, updates are rendered in a single, uninterrupted transaction - once it starts, it can’t be stopped until it’s finished.

With concurrent rendering, React can interrupt - stop or pause - rendering the updates. DOM mutation is left until the last moment when the entire tree has been evaluated.

A lot of new React will be based on concurrency support.


React 18 has introduced new concepts and different types of updates - urgent and transitional updates.

Urgent updates are usually instances whereby we have a natural expectancy of how and how fast they should work. Pressed key should be shown in an input field immediately if possible. Results from search based on the input value aren’t expected immediately, so such updates aren’t considered urgent and could be labelled as transitional updates.

Transitional updates are marked using the startTransition() method or useTransition() hook.

Plain Text
import { startTransition } from 'react';
startTransition(() => {


Every React developer has had to deal with a loading state at some point - displaying a spinner while waiting for something to load.

Plain Text
const [saving, setSaving] = useState(false)
if (loading) {
  return <Spinner />
return (
  <Component />

To help with that, there is Suspense. It lets you declaratively specify the loading state for a part of the component tree if it's not yet ready to be displayed:

Plain Text
<Suspense fallback={<Spinner />}>
  <Component />

With Suspense, your component can suspend its rendering until ready. When React notices such a state, it will traverse up the tree to the nearest Suspense component and will display the value from fallback while the component is getting ready.

With React 18’s Suspense, two major SSR features are added:

  • HTML streaming
  • Selective hydration

With HTML streaming, by wrapping components in , we can tell React that it doesn’t need to wait for the wrapped component to start streaming the HTML. React will send the placeholder (Suspense’s fallback).

With selective hydration, we can wait for a component of code to load without blocking the rest of the page - hydration can start before all of the code is loaded. Now React.lazy() works with SSR.


Hydration is a process of rendering the components and attaching event handlers. It’s important when using Server Side Rendering where non-interactive (sometimes called ‘dry’) HTML and CSS are rendered on the server and sent to the client. After that, the ‘hydration’ of ‘dry’ content happens, after which users can interact with the content. It’s useful because users can actually see some content even before they can interact with it, making the whole experience a lot smoother. hydrateRoot() method from react-dom/client has been added to hydrate a server rendered application.


To finalise the React 18 migration, some additional changes were required in tests. act() was introduced to React with hooks as useEffect() was starting to schedule asynchronous work. When testing components with useEffect() without act() , sometimes asynchronous effects wouldn’t fire, and tests wouldn’t match production behaviour.

As in concurrent React 18, a lot more work is happening asynchronously, and a lot more situations require act() . Now, React will return a warning if it receives an update that wasn’t wrapped in act() .

This led to a few tests displaying warnings about missing act() in our codebase, but they are easy to fix as it just requires wrapping a few fired events.

The disheartening of peer-dependant

“It’s turtles all the way down” is an ancient expression about how much packages depend on each other. And for that reason, it’s sometimes not possible to fully migrate to a newer version of one package because it’s not yet supported by other packages used in the project.

Package developers will start working on supporting new versions of packages their package relies on before official release is ready. For example, react@18.0.0 was released on 29th of March, 2022 and react-testing-library@13.0.0 followed right after and was released on 31st of March, 2022 with a very clear changelog message:

“BREAKING CHANGES: - Drop support for React 17 and earlier.“.

But not all package maintainers have enough resources to provide support for the latest versions of libraries that you would like to use. And thus, we wait…

Insight, imagination and expertly engineered solutions to accelerate and sustain progress.