Introducing React v18 with real-world examples

A simplified overview of the latest features introduced with React v18. Automatic Batching, new hooks, improved ReactDOM API, and much more!

#react
#javascript
#hooks

At the time of writing, the React core team has just released the latest v18.0.0, a version designed to fix various issues and introduce incredible new features to make our web perform better.

Since 2013, when it first released the 0.3.0 version, the React.js library changed how we think about Web development.

It did not introduce any new concept, the web community had been talking about web components and libraries/frameworks long before React was adopted. What it has made possible is to think of a page as a puzzle, where we create our pieces and then put them together.

I honestly admit that React is not perfect, although I love it there are some compromises when using it compared to other more comprehensive libraries or frameworks. But, the way it has evolved and grown based on user feedback has given us one of the best libraries we currently use for web development.

Through all the releases and changes introduced, the community and core team working on React have added incredibly powerful functionalities, we can't forget how the introduction of hooks was a game-changer in 2019 when v16.8.0 was released.

React v18 features

This new release introduces many core changes at different levels, some of which have a small impact on how a React app works while others have come to drastically improve performance or cover particular needs.

The React core team made a great introduction to this version in their blog, but what I missed were real examples of these new features that can give a boost to a React app.

After further experimentation, I want to share how React v18 impacts your apps through real examples, those code snippets you commonly use in a React project.

New ReactDOM API

ReactDOM has so far been the default tool for assembling React applications and interacting with DOM nodes.

With v18 of this package, the default methods exposed by this package are considered deprecated in favor of a new pair of modules specifically for client/server rendering: react-dom/client and react-dom/server.

ReactDOMClient

This module gives us two new methods for rendering React applications in the client:

  • createRoot(container): this method gets a mandatory DOM node and returns a root instance we can use to mount or unmount a React tree. Compared to the previous API, we could render an application as follows in this example:
jsx
/**
* 👴 Old way to mount and unmount an app
*/
import ReactDOM from 'react-dom';

const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);
ReactDOM.unmountComponentAtNode(rootNode);

/**
* 🚀 New way with the react-dom/client API
*/
import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
root.unmount();

  • hydrateRoot(element, container): this new version of the API for hydrating pre-rendered content from ReactDOMServer is not very different from how we used it before with the old versions of the library.

    Like the createRoot() method, it returns an instance of the root. However, for the hydrating process, it is not necessary to invoke the render method as the initial child is passed directly to the hydrateRoot() call:

jsx
/**
* 👴 Old way to hydrate an app server-side rendered
*/
import ReactDOM from 'react-dom';

const rootNode = document.getElementById('root');
ReactDOM.hydrate(<App />, rootNode);

/**
* 🚀 New way with the react-dom/client API
*/
import ReactDOM from 'react-dom/client';

const rootNode = document.getElementById('root');
const root = ReactDOM.hydrateRoot(rootNode, <App />);

Both these methods accept two options to configure the behavior in case of errors or to set a prefix to the return value of useId (we will talk more about this new hook): onRecoverableError and identifierPrefix.

There's no need to dive deeper into these options right now, you can find more details in the React documentation.

ReactDOMServer

With the new module for server rendering our React apps, we can now use two new methods:

  • renderToPipeableStream: this new method is designed to progressively render HTML on the server (as a stream) and send it to the client, conveying the newly generated HTML in the response and injecting the new part into the page using a simple <script /> tag.

    Moreover, the real game-changer is the fact that this method allows Suspense to be used to its full potential on the server, giving a real boost when rendering apps composed of expensive computational parts that are not needed on the first render:

jsx
import ReactDOMServer from 'react-dom/server';

ReactDOMServer.renderToPipeableStream(<App />, options);

function App() {
return (
<Content>
<Navbar />
<TodoForm />
<Suspense fallback={<Spinner />}>
<TodoList />
</Suspense>
<TodoForm />
</Content>
);
}

  • renderToReadableStream: this second new method works similarly to the one we have just seen, with the difference that it is made to work in Web Streams environments such as Cloudflare.

Automatic Batching

Improved Automatic Batching is one of the features I'm most excited about! It brings a big improvement to React's rendering performance by grouping multiple status updates into a single rendering.

I say "improved" because this behavior was already implemented in previous versions of React, but it only worked under certain conditions:

  • Multiple state updates inside an event handler:
jsx
function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});

function handleClick() {
setLoading(true); // ⏳ Wait till the end to re-render
setData({}); // ⏳ Wait till the end to re-render
// 🚀 Now it re-renders once, batching the previous state updated!
}

return <Button onClick={handleClick}>Click me!</Button>;
}

  • Multiple state updates inside a side effect:
jsx
function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});

useEffect(() => {
setLoading(true); // ⏳ Wait till the end to re-render
setData({}); // ⏳ Wait till the end to re-render
// 🚀 Now it re-renders once, batching the previous state updated!
}, []);

...
}

The main issue with the previous version of React is that the batching mechanism was not prepared for updating on asynchronous operations and event listeners, which results in multiple rerenders when updating separate states.

For example, let's check a couple of real scenarios where automatic batching was not really "batching":

  • Asynchronous HTTP requests
jsx
function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});

function handleClick() {
fetchJokes().then(data => {
setLoading(false); // 😱 Re-render the component
setData(data); // 😱 Re-render the component
});
}

return <Button onClick={handleClick}>Get jokes!</Button>;
}

  • Timer functions
jsx
function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});

function handleClick() {
setTimeout(() => {
setLoading(true); // 😱 Re-render the component
setData({}); // 😱 Re-render the component
}, 500);
}

return <Button onClick={handleClick}>Click me!</Button>;
}

  • Event listeners
jsx
function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});
const buttonRef = useRef();

useEffect(() => {
buttonRef.current.addEventListener('mouseenter', () => {
setLoading(true); // 😱 Re-render the component
setData({}); // 😱 Re-render the component
});
}, []);

return <Button ref={buttonRef}>Click me!</Button>;
}

All of these reported use cases, which you will certainly have seen in various codebases, are in fact both correct and flawed at the same time, as they would have led to multiple re-renders even when not needed.

So, thanks to this latest update, all these scenarios will now behave correctly, grouping sequential status updates and improving the performance of a React application.

How to disable Automatic Batching?

In certain exceptional cases, you may wish to update the DOM immediately and avoid batching a status update. For this purpose, ReactDOM exposes the .flushSync() method to force a callback to run synchronously:

jsx
import { flushSync } from 'react-dom';

function App() {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState({});

function handleClick() {
flushSync(() => {
setData({}); // ⚠️ React immediately sets this state
});
setLoading(true);
}

return <Button onClick={handleClick}>Click me!</Button>;
}

I would not recommend using this method unless, after analyzing the behavior of the component in-depth, you have a good reason to force a status update immediately.

The main reason to avoid using it excessively is because it would hurt the performance of the application.

Concurrent React

Speaking of Concurrent React, the React core team introduced one of the biggest game-changer since the React Hooks paradigm.

The concurrent mode comes with some important behaviors that don't directly affect the way we write React code, or ideally, we wouldn't bother.

Concurrent React it’s a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time.

React core team

The main difference concurrent mode introduces is that component renders are interruptible.

In all previous implementations of React, the rendering of a component was an atomic transaction, based on triggers such as status updates: once the render starts, nothing can block it, limiting the concept of rendering priority.

Rendering priority is a term not used by the React team to explain this concept because it's not what it does behind the scene, but I think it would help React developers to better understand what they do when using related features like the new Transition API.

Listing a couple of scenarios we could implement with concurrent mode, we may think about:

  • Pausing one render in favor of another: how often did you find yourself creating a SearchBar component that retrieves fresh results as you type? This is a well-known feature that many web application implements, and they all face the challenge of throttling search input to avoid requesting their server on every keystroke.

    When updating the search query, it is difficult to tell React to prioritize updating the input over the list of results, that desperately try to re-render it with the new query and retrieve fresh data.

    To prevent these two elements from running against each other to re-render first, we will shortly see a way to prioritize the rendering of a component with the new useDeferredValue hook.

jsx
function Dashboard({ movies }) {
const [query, setQuery] = useState('');

return (
<Container>
<Input onChange={/* Update query */} value={query} />
<MovieList movies={movies} querySearch={query} />
</Container>
);
}

  • Reusable state & Offscreen API: Do you know that scenario in which, when rendering a component with its state and then unmounting, that state will be lost and we'll restart with the initial state? This is a common situation in React that we can best explain with a Counter component.
jsx
function App() {
const [shouldRenderCounter, setVisibility] = useState(true);

return (
<Container>
<Button onClick={() => setVisibility(true)}>Show counter</Button>
<Button onClick={() => setVisibility(false)}>Hide counter</Button>
{shouldRenderCounter && <Counter />}
</Container>
);
}

function Counter() {
/**
* 😢 When the component unmounts, this state is lost and
* restarts from 0 when the components are rendered again
*/
const [count, setCount] = useState(0);

return (
<Container>
<Paragraph>Current count: {count}</Paragraph>
<Button onClick={() => setCount(c => c + 1)}>Increase count!</Button>
</Container>
);
}

As can be seen, the <Counter /> component gets mounted and unmount depending on user interaction. This results in the component state getting flushed every time the component is unmounted.

The React team is working on an Offscreen API that will allow to restore components re-using their previous state, to give a smoother user experience when hiding/showing off-screen elements.

Of course, this won't always be the wanted experience, we may want to reset the counter! For this reason, I guess there will be workarounds to waive this behavior when it'll work consistently on an upcoming minor release of React v18.x.x.

Transition API

The new Transition API is a feature that allows React developers to leverage concurrent mode in React.

It is used to distinguish between Urgent updates, so those that should take priority over all others, and Transition updates, those that can be blocked/paused.

At first glance you might think "Great, now I've got a way to tell React which renders I want to prioritize!", but the truth is that it works the other way around!

The new Transition API marks those re-renders that you consider "interruptible". All of this just means you can think of it as:

Hey React, you can pause these renders if something more important is happening!

React Developer

The API gives us two ways to set a transition update and so set the priorities as we would like:

  • useTransition hook: is a native hook that allows us to initiate transitions, to mark as non-priority some updates.

    It returns an array with a boolean value that tells when a transition is occurring, usually called isPending, and the second element will be a function to start the transition, simply named startTransition.

  • startTransition(): the other way to start transitions when we cannot call the useTransition hook, this is a standalone function you can import directly from react and use it to start a transition.

    N.B. Using this API, you won't benefit from the isPending boolean value returned using the useTransition hook.

Now that we've clarified what the role of the Transition API is, let's make it clear with a final example!

Suppose you have a list of movies and you want to filter it by a title that you can type into an input field. Each time you press a key, a query value is updated, and you use it to filter the list and update the input value. This could create a race to "which to re-render first", which is particularly tricky since the list is a huge component that needs some time to re-render.

Let's check the two implementations by leaving all the re-renders as urgent updates and then marking the re-rendering of the list as a Transition update:

No transition, every update is urgent

jsx
function Dashboard({ movies }) {
const [query, setQuery] = useState('');

function handleChange(event) {
setQuery(event.target.value);
}

return (
<Container>
<Input onChange={handleChange} value={query} />
/** 🐌 Typing, the UI feels blocked since the MovieList receives the query with the same
priority as the Input */
<MovieList movies={movies} querySearch={query} />
/** 🐌 The list slowly re-renders on each keystroke with the same priority as the Input */
</Container>
);
}

Use a transition, the MovieList renders when no urgent task is pending

jsx
function Dashboard({ movies }) {
const [query, setQuery] = useState('');
const [querySearch, setQuerySearch] = useState('');

const [isPending, startTransition] = useTransition();

function handleChange(event) {
setQuery(event.target.value);
startTransition(() => {
setQuerySearch(event.target.value);
});
}

return (
<Container>
<Input onChange={handleChange} value={query} />
/** 🚀 Typing, the UI feels smooth since the MovieList re-render is paused */
<MovieList movies={movies} querySearch={querySearch} />
/** 🛑 ✋ The list re-renders when the more urgent Input re-render will be done */
{isPending && <Text>The list is waiting to re-render!</Text>}
</Container>
);
}

There is a nicer way to get the same result using the concurrent mode, we'll see in a moment with the useDeferredValue hook.

Improved Suspense behaviors

<Suspense /> has been the elephant in the room for a long time in the React community.

When it was first announced, the React team promised that they would work on it for better support for server-side rendering and data fetching, not just using it for code split integration with React.lazy.

However, we haven't seen as much progress as expected over the past couple of years, until now!

Finally, Suspense has better support in various aspects, which we could summarize with four main points:

  • Support Server-side rendering: combined with the previously introduced ReactDOM APIs, renderToPipeableStream and renderToReadableStream, Suspense is now capable of splitting the parts of an App we want to render in the server and hydrating on the client the HTML without waiting for the code-splitted parts.

    This is an incredible performance improvement since it allows to load on the first response only the necessary bundled code and HTML. A small example is already provided in the ReactDOMServer section.

  • Transitions to keep existing content while loading: this is something I've personally waited for since many times I would have loved to keep my rendered content in Suspense and prevent it to render the fallback while loading a new chunk of code!

    So far, Suspense worked rendering the fallback define in the closest <Suspense /> parent component, which not always is the expected result if something is already rendered and we want to keep it on the screen until it'll be replaced with the new component, instead of showing the fallback while the new resource is loaded.

    With v18, we can use the Transition API to have this behavior when necessary! Also, we can provide immediate feedback to the user during the transition between the old rendered content and the newly fetched content using the isPending boolean returned by useTransition().

jsx
function Dashboard({ movies }) {
const [shouldRender, setVisibility] = useState(false);

const [isPending, startTransition] = useTransition();

function handleVisibility(event) {
startTransition(() => {
setVisibility(true);
});
}

return (
<Container>
<Suspense fallback={<Skeleton />}>
{isPending && <Text>Something new will render soon...</Text>}
{shouldRender ? <MovieList movies={movies} /> : <NoContent />}
</Suspense>
</Container>
);
}

  • Trees are always consistent: this change applies a different algorithm at the time of suspending a component and loads the relative fallback in the DOM.

    It changes the way React renders the "hole" where a component should render when ready, and consistently wait to render the whole tree defined inside Suspense to prevent adding to the DOM elements part of the tree and hide them with display: none, as it did so far in past implementations. I think the way this process is explained step-by-step is great in the official Suspense RFC.

  • Re-run layout effect when content reappears: this behavior is also quite particular. It implies re-running layout effects defined in a component only on hide and shows. To better understand it, let's split it into steps:

    • Run the cleanup functions of the layout effects when Suspense needs to hide its content while loading new data.
    • When the new data is ready, run the layout effects again as it rendered them on the previous render.

It's not necessary to understand this new behavior right now, it is more related to how the new StrictMode will help to reuse state across renders.

New StrictMode & Reusable state

Another big change the core React team is planning to add in a future release is to make possible pieces of UI, mounting and unmounting them by reusing the existing state.

We already talked about it in a previous section while describing concurrent mode. However, we didn't talk about what's the plan to adopt it.

To achieve a reusable state for components, React needs to unmount and remount the whole component tree using the previous state. Getting to this point is not easy, since we should assure components' effects must be consistent if mounted and destroyed multiple times.

This is one of the main requirements, and with the new StrictMode behavior, React helps identify if those effects are resilient to changes and would work in the future with reusable states.

The new behavior introduced in StrictMode and working only in development mode consists of simulating an effect to run and be immediately destroyed, to finally run again on the component.

This is kind of weird at first impact, but it makes total sense if we want to control whether an effect changes the behavior of our component or keeps it consistent across re-renders.

We can see with a simple example how it would work before React v18 and with the new StrictMode.

jsx
function App() {
console.log('🚀 Render!');

useEffect(() => {
console.log('🚀 Run effect!');

return () => console.log('💣 Run effect destroy!');
}, []);

return <Content />;
}

Using previous versions of React, this component would render and run the effect as we well know:

text
🚀 Render!
👀 Run effect!

But, when switching to React v18, what will happen (again, this is only a development mode check made by StrictMode, it won't affect your production performance) will be:

text
🚀 Render!
👀 Run effect!
💣 Run effect destroy!
👀 Run effect!

This will help ensure state reusability when React will introduce this new functionality!

New React Hooks

The v18 release brought many changes, and the introduction of five new hooks in addition to the existing set looks like the React team listen to the needs of the community and provided great solutions in response!

I said so because the hooks I'll introduce now will especially help library maintainers improve existing implementations for some of our favorite libraries, like those based on CSS-in-JS, routing etc.

To recap, will now see examples of these new hooks:

useId

This has been one of the most discussed because I believe many of us also misunderstood what is its final role inside a component.

It is useful to generate a unique id across server/client rendering, being consistent with the generated value so that the hydration process can be specular and avoid mismatches on the rendered HTML.

What many developers thought at first impact was "Oh finally, a way to create keys for list rendering when there is not an id available!", but the React Docs explicitly states this is not the purpose for using this hook.

jsx
function Dashboard({ movies }) {
/**
* 🆔 Will create a unique id like ":r0:"
*/
const id = useId();

return (
<Container>
<Label htmlFor={id}>Age</Label>
<Input id={id} />
</Container>
);
}

As explained earlier, we could pass an identifierPrefix property to the ReactDOMClient.hydrateRoot() method to specify a prefix on the generated IDs.

useTransition

We've already seen a good example of how to start a transition to distinguish interruptible updates from those that are considered urgent.

To rehearse what we already said before, the useTransition hook is the default choice when we want to trigger state updates and mark them with less priority over the others, over the fact it is useful to transition UIs while Suspense loads fresh data.

useDeferredValue

Writing this introduction, I've quoted many times this hook as useful when is necessary to set a value as "less important" and we want to leave it a the end of our update cycles.

To see how we can use it, we can refactor a previous example with this hook to get a cleaner implementation:

jsx
function Dashboard({ movies }) {
const [query, setQuery] = useState('');

const deferredQuery = useDeferredValue(query);

function handleChange(event) {
setQuery(event.target.value);
}

return (
<Container>
<Input onChange={handleChange} value={query} />
/** 🚀 Typing, the UI feels smooth since the deferredQuery value is updated when no urgent
updates are running */
<MovieList movies={movies} querySearch={deferredQuery} />=
</Container>
);
}

The result is much cleaner than the previous implementation. However, this does not mean it is the preferred way to go when working in concurrent mode, it depends on the needs and expected behavior of a component and what to use between the available options.

useInsertionEffect

This hook is mainly thought to be used in libraries implementation such as those dedicated to CSS-in-JS since it allows to modify of the DOM before any layout effect run.

We can see the order executions of the hooks with the following snippet:

jsx
function App() {
useEffect(() => {
console.log('👀 Run effect');
});

useInsertionEffect(() => {
console.log('🎨 Run insertion effect');
});

useLayoutEffect(() => {
console.log('⏰ 🎨 Run layout effect');
});

return <Content />;
}

/**
* It will print in this order
* 🎨 Run insertion effect
* ⏰ 🎨 Run layout effect
* 👀 Run effect
*/

To give a more practical example, assume you are the creator of a famous library such as styled-components, and you'd like to insert the new CSS classes in a style tag before any layout effect could read the DOM:

jsx
const styled = Component => styles => {
return function StyledComponent(props) {
const className = getClassName(styles);

useInsertionEffect(() => {
insertNewCSSStyles(className, styles);
});

return <Component className={className} {...props} />;
};
};

This will guarantee the newly created styles will be inserted before other effects can try to measure changes.

useSyncExternalStore

Last but not least, the useSyncExternalStore is fully dedicated to library maintainers that work with state management.

Its purpose is to allow external stores such as those provided by Redux and similar to be able to use concurrent mode and force updates on them to be synchronous.

I honestly think a small percentage of users will ever use it, but if you'd like to know more you can read its syntax in the official documentation.

Some final interesting changes

  • Console logs are not suppressed anymore: working in StrictMode React renders twice a component for better debugging on side effects. In previous versions, console.logs were suppressed for one of the renders to not pollute the console. After getting feedback from the community, the React team decided to remove this suppression, so if you see logs twice in your console and don't see why don't worry: you are not crazy 😂 In case you want to remove them, there is an option to suppress those extra logs.

  • No warning about updating states on unmounted components: do you know that warning threatening to update states on unmounted components can create memory leaks in your app? Now is gone!

  • Components can now render undefined: before v18, React always warned about returning undefined in a component, but with the latest version they also removed this console warning.

A note about Server Components

Unfortunately, Server Components are still in the development phase and the React core team promised they'll release an initial version in a future minor of v18, but we've got to wait a little bit more.

However, if you are interested in the topic, you'll find many details about the development steps in the RFC: React Server Components.

Conclusion

This version has come to be a huge improvement on how we work with React and what we can deliver in terms of User experience. I love the fact that performance is a big concern in the React community and that we keep getting improvements on it such as the Automatic Batching.

I'll probably write more in the future about React v18 and its minor versions, hopefully providing some useful examples to the community! 🤓

Last updated: