Exploring React 18’s three new APIs
February 05, 2022 - 10 minutes - Originally published elsewhere.
The biggest topic in the React ecosystem right now is React 18 and the full release of its highly-anticipated concurrent rendering features. In June 2021, the React team announced the plan for React 18 and what was to come. Several months later, in December, the main topic of React Conf 2021 was all of the newly announced concurrent rendering features.
Together with React 18, several new APIs were shipped that allow users to make full use of React’s concurrent rendering capabilities. These Hooks are:
This article will cover these three new APIs, their use cases, what problems they solve, why they were added, and how they integrate into the realm of concurrent rendering.
A note before we begin
As all of these new APIs are related to concurrent rendering, I’d recommend that you first familiarize yourself with the concept and why the React team is focusing on it so much. A good place to begin is the React 18 announcement from React or the working group announcement. After that, the following sections will make a lot more sense.
The useSyncExternalStore
Hook
One of the APIs introduced in React v16.14.0 to accommodate concurrent rendering was useMutableSource
, which was intended to allow React components to safely and efficiently integrate with external mutable sources during concurrent rendering.
The Hook would attach to a data source, wait for changes, and schedule updates accordingly. All of this would happen in a way that would prevent tearing, which is when visual inconsistencies arise because there are multiple values for the same state.
This is an especially prominent issue with the new concurrent rendering features because state flows can become intertwined very quickly. However, adopting useMutableSource
proved to be difficult for the following reasons:
1. The Hook is naturally asynchronous
The Hook doesn’t know whether it can reuse the resulting value of the selector
function if it changes. The only solution was to re-subscribe to the provided data source and retrieve the snapshot again, which can cause performance issues because it happens on every render.
For users and libraries (like Redux), it meant that they had to memoize every single selector in their project and weren’t able to define their selector
functions inline because their references weren’t stable.
2. It has to deal with external state
The original implementation was also flawed because it had to deal with states that live outside of React. This meant the state could change at any time due to its mutability.
Because React tried to solve this asynchronously, this would sometimes cause visible parts of the UI to be replaced with a fallback, resulting in a sub-optimal user experience.
All of this made it a painful migration for library maintainers and a suboptimal experience for both developers and users.
Solving these issues with useSyncExternalStore
To address these problems, the React team changed the underlying implementation and renamed the Hook to useSyncExternalStore
to properly reflect its behavior. The changes include:
- Not re-subscribing to the external source every time the selector (for the snapshot) changes — instead, React will compare the resulting values of the selectors, not the selector functions, to decide whether to retrieve the snapshot again, so that users can define selectors inline without negatively impacting performance
- Whenever the external store changes, the resulting updates are now always synchronous, which prevents the UI from being replaced with a fallback
The only requirement is that the resulting value of the getSnapshot
Hook argument needs to be referentially stable. React uses this internally to determine whether a new snapshot needs to be retrieved, so it either needs to be an immutable value or a memoized/cached object.
As a convenience, React will provide an additional version of the Hook that automatically supports memoization of getSnapshot
’s resulting value.
How to use useSyncExternalStore
// Code illustrating the usage of `useSyncExternalStore`.
// Source: <https://github.com/reactwg/react-18/discussions/86>
import {useSyncExternalStore} from 'react';
// React will also publish a backwards-compatible shim
// It will prefer the native API, when available
import {useSyncExternalStore} from 'use-sync-external-store/shim';
// Basic usage. getSnapshot must return a cached/memoized result
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);
// Code illustrating the usage of the memoized version.
// Source: <https://github.com/reactwg/react-18/discussions/86>
// Name of API is not final
import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector';
const selection = useSyncExternalStoreWithSelector(
store.subscribe,
store.getSnapshot,
getServerSnapshot,
selector,
isEqual
);
The useId
Hook
Running React on the server side
For a long time, a React project only ran on the client side. In short, this meant that all of the code was sent to the user’s browser (the client) and the browser was then responsible for rendering and showing the application to the user.
React as a whole has been expanding towards the realm of server-side rendering (SSR). In SSR, a server is responsible for generating the HTML structure based on the React code. Instead of all the React code, only the HTML is sent to the browser.
The browser is then only responsible for taking that structure and making it interactive by rendering the components, adding CSS on top of it, and attaching JavaScript to it. This process is called hydration.
The most important requirement for hydration is that the HTML structures generated by the server and the client have to match. If they don’t, the browser isn’t able to determine what it should do with that certain part of the structure, which results in incorrectly rendered or non-interactive UI.
This is especially prominent in features that depend on identifiers because they have to match on both sides, such as when generating unique styling class names and accessibility identifiers.
The evolution of the useID
Hook
To address this, React initially introduced the useOpaqueIdentifier
Hook, but unfortunately, it also had some issues:
-
In different environments, the Hooks would yield different outputs (opaque):
- Server-side: it would produce a string
- Client-side: it would produce a special object that had to be passed directly to a DOM attribute
This meant the Hook could only produce a single identifier, and that it wasn’t possible to generate new IDs dynamically because it has to obey the rules of Hooks. So, if your component required X different identifiers, it would have to call the Hook X different times, which obviously doesn’t scale well in practice.
// Code illustrating the way `useOpaqueIdentifier` handles the need for N identifiers in a single component, namely calling the hook N times.
// Source: <https://github.com/facebook/react/pull/17322#issuecomment-613104823>
function App() {
const tabIdOne = React.unstable_useOpaqueIdentifier();
const panelIdOne = React.unstable_useOpaqueIdentifier();
const tabIdTwo = React.unstable_useOpaqueIdentifier();
const panelIdTwo = React.unstable_useOpaqueIdentifier();
return (
<React.Fragment>
<Tabs defaultValue="one">
<div role="tablist">
<Tab id={tabIdOne} panelId={panelIdOne} value="one">
One
</Tab>
<Tab id={tabIdTwo} panelId={panelIdTwo} value="one">
One
</Tab>
</div>
<TabPanel id={panelIdOne} tabId={tabIdOne} value="one">
Content One
</TabPanel>
<TabPanel id={panelIdTwo} tabId={tabIdTwo} value="two">
Content Two
</TabPanel>
</Tabs>
</React.Fragment>
);
}
Certain accessibility APIs like aria-labelledby
can accept multiple identifiers through a space-separated list, but because the Hook’s output is formatted as an opaque data type, it always has to be directly attached to the DOM attribute. This meant that it wasn’t possible to properly use the aforementioned accessibility APIs.
To address this, the implementation was changed and renamed to useId
. This new Hook API generates stable identifiers during SSR and hydration to avoid mismatches. Outside of server-rendered content, it falls back to a global counter.
Instead of creating an opaque data type (a special object in the server and a string in the client) as done with useOpaqueIdentifier
, the useId
Hook produces a non-opaque string on both sides.
This means that if we need X different IDs, it’s not necessary to call the Hook X times anymore. Instead, a component can call the useId
once and use it as a base for the identifiers that are necessary throughout the component (e.g., using a suffix) because it’s just a string. This solves both of the issues that were present in useOpaqueIdentifier
.
How to use useID
The below code example illustrates how to use useId
based on what we discussed above. Because the generated IDs by React are globally unique and the suffixes are locally unique, the dynamically created IDs are also globally unique — and thus won’t cause any hydration mismatches.
// Code illustrating the improved way in which `useId` handles the need for N identifiers in a single component, namely calling the hook once and creating them dynamically.
// Source: <https://github.com/reactwg/react-18/discussions/111>
function NameFields() {
const id = useId();
return (
<div>
<label htmlFor={id + '-firstName'}>First Name</label>
<div>
<input id={id + '-firstName'} type="text" />
</div>
<label htmlFor={id + '-lastName'}>Last Name</label>
<div>
<input id={id + '-lastName'} type="text" />
</div>
</div>
);
}
The useInsertionEffect
Hook
Issues with CSS-in-JS libraries
The last Hook that will be added in React 18 — and that we’ll discuss here — is useInsertionEffect
. This one is slightly different from the other ones, as its sole purpose is important for CSS-in-JS libraries that generate new rules on the fly and insert them with <style>
tags in the document.
In certain scenarios, <style>
tags need to be generated or edited on the client side, which can cause performance issues in concurrent rendering if not done carefully. This is because when CSS rules are added or removed, the browser has to check whether or not those rules apply to the existing tree. It has to recalculate all of the style rules and reapply them — not just the changed ones. If React finds another component that also generates a new rule, the same process would occur again.
This effectively means that CSS rules have to be recalculated against all DOM nodes for every frame while React is rendering. While there’s a decent chance you won’t hit this issue, it’s not something that scales well.
Theoretically, there are ways around it that mostly have to do with timing. The best solution to this timing issue would be to generate these tags at the same time as all the other changes to the DOM, like when the React library does. Most importantly, it should happen before anything tries to access the layout and also before everything is presented to the browser to paint.
This sounds like something useLayoutEffect
could solve, but the problem is that the same Hook would then be used for both reading layout and inserting styling rules. This could cause undesired behavior, such as computing the layout multiple times in a single pass or reading the incorrect layout.
How useInsertionEffect
solves concurrent rendering problems
To address this, the React team has introduced the useInsertionEffect
Hook. It’s very similar to the useLayoutEffect
Hook, but it doesn’t have access to refs of DOM nodes.
This means that it can only be to insert styling rules. Its main use case is to insert global DOM nodes like <style>
, or SVGs <defs>
. As this is only relevant for generating tags client-side, the Hook doesn’t run on the server.
// Code illustrating the way `useInsertionEffect` is used.
// Source: <https://github.com/reactwg/react-18/discussions/110>
function useCSS(rule) {
useInsertionEffect(() => {
if (!isInserted.has(rule)) {
isInserted.add(rule);
document.head.appendChild(getStyleForRule(rule));
}
});
return rule;
}
function Component() {
let className = useCSS(rule);
return <div className={className} />;
}
Final thoughts
The most anticipated features of React 18 are its concurrent rendering features. With the team’s announcement, we received new APIs that will allow users to adopt concurrent rendering features based on their use cases. While some are completely new, others are improved versions of previous APIs based on community feedback.
In this article, we covered the three latest APIs, namely the useSyncExternalStore
, useId
, and useInsertionEffect
Hooks. We have taken a look at their use cases, the problems they addressed, why certain changes were necessary compared to their previous versions, and what purposes they serve for concurrent rendering.
Packed with new features, React 18 is definitely something to look forward to!