React 18 New Hooks, useId, useSyncExternalStore, and When You'll Actually Use Them
TL;DR —
useIdis for SSR-safe unique IDs (a11y); use it.useSyncExternalStoreis for state library authors integrating with React 18 (Redux, Zustand); you probably don’t write it yourself.useInsertionEffectis for CSS-in-JS library authors; same.
Five new hooks in React 18. Three are useful only if you’re writing libraries; two are useful in app code. This post sorts them, with practical examples for the ones you’ll touch.
useId — for SSR-safe unique IDs
Problem: you need a unique id attribute for accessibility (label-input pairing, ARIA referencing). Math.random() doesn’t work — it generates different IDs on server vs client, breaking SSR hydration.
Pre-React 18, the workarounds were ugly: pass IDs as props, use libraries like react-aria that handled it, or just accept the hydration warning.
useId is the official answer:
import { useId } from 'react';
function PasswordField() {
const id = useId();
return (
<>
<label htmlFor={id}>Password</label>
<input id={id} type="password" />
<p id={`${id}-help`}>Min 12 chars</p>
<input aria-describedby={`${id}-help`} />
</>
);
}
The ID is deterministic per-component-tree-position, so server and client agree. It’s also unique across the app, so you can use it as a prefix for multiple related IDs.
What it looks like in markup: :r0:, :r1:, etc. — short, valid HTML, predictable.
Two notes:
- Don’t use it as a
keyin lists.useIdis per-component, not per-item. Use the data’s own ID for keys. - The IDs aren’t human-readable. That’s fine for accessibility relationships; not for debugging IDs you’d grep for in logs.
Use this. It’s the right answer.
useSyncExternalStore — for state library authors
If you write an app, you don’t usually call useSyncExternalStore directly. You use Redux (5+) or Zustand or Jotai, and those libraries use useSyncExternalStore internally to integrate with React 18’s concurrent features correctly.
The why: React 18’s tearing problem. With concurrent rendering, a render can be interrupted and resumed. If your component reads from an external store (like Redux) during render, the store’s value might change mid-interrupt — different components in the same render could see different values. That’s “tearing.”
useSyncExternalStore solves it by giving React a contract:
import { useSyncExternalStore } from 'react';
function useStoreValue(store, selector) {
return useSyncExternalStore(
(callback) => store.subscribe(callback), // subscribe — return unsub
() => selector(store.getState()) // get current value (client)
);
}
React uses this contract to take a consistent snapshot during render.
For app developers: just use Redux 8 / Zustand 4 / Jotai — they’re all updated to use this internally. You get tearing-free reads for free.
Don’t write your own external store integration with useSyncExternalStore unless you have a very specific reason. The state libraries do this better than you will.
useInsertionEffect — for CSS-in-JS library authors
Even narrower. useInsertionEffect runs before layout effects, before the browser has a chance to paint. The purpose: CSS-in-JS libraries (styled-components, emotion) need to inject styles before any component reads layout, otherwise you get layout thrashing.
useInsertionEffect(() => {
insertStyleTag(rule);
});
Don’t use this in app code. The doc literally says “We don’t recommend using useInsertionEffect outside of CSS-in-JS libraries.” Believe it.
If you do CSS-in-JS in 2022, upgrade your library to a version that uses useInsertionEffect internally. The benefit is zero layout thrash; you don’t have to write the integration.
useTransition, useDeferredValue (recap)
Already covered in the concurrent rendering post. These are app-code hooks. Use them for input responsiveness during expensive renders.
Putting useId in context: full a11y form example
import { useId, useState } from 'react';
function CheckoutForm() {
const emailId = useId();
const errorId = useId();
const [email, setEmail] = useState('');
const [error, setError] = useState(null);
function handleSubmit(e) {
e.preventDefault();
if (!email.includes('@')) {
setError('Email must contain @');
return;
}
setError(null);
// submit...
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert">{error}</p>
)}
<button type="submit">Continue</button>
</form>
);
}
Each useId generates a unique-per-mount ID. The form has correct label + input pairing and correct aria-describedby wiring for the error. SSR-safe. No collisions if you render multiple <CheckoutForm>s on the same page.
This pattern replaces the “pass an id prop down” gymnastics that React forms required pre-18. Cleaner component contracts.
Common Pitfalls
Using useId as a list key. It’s stable per-component-position, not per-item. Use the item’s own ID.
Calling useId conditionally. Same rule as all hooks — must be unconditional. Doesn’t change for useId.
Reaching for useSyncExternalStore in app code. Almost always the wrong move. Use a state library that already integrates it.
Implementing your own subscribe/getSnapshot for built-in browser state. React 18 ships utilities for many cases (useSyncExternalStore for window.location, localStorage, etc.). Check the React docs before rolling your own.
Using useInsertionEffect in components. Don’t. CSS-in-JS libraries only.
Treating these hooks as performance tools. They’re correctness tools — preventing tearing, fixing SSR ID collisions, ensuring style insertion ordering. Performance is a side-benefit of correctness.
Wrapping Up
useId is the new hook you’ll use in app code. The others are infrastructure for libraries to integrate with React 18 correctly. Most app-developer engagement is “upgrade your state lib + your CSS-in-JS lib to React-18-aware versions.” Wednesday: the actual upgrade walkthrough — from npm install to verifying you haven’t broken anything.