Difficulty: Advanced
What are useId and useSyncExternalStore hooks in React 18?
Let's break down useId and useSyncExternalStore, two React 18 hooks that solve very specific but important problems. They're not hooks you'll use every day, but understanding them shows a deep grasp of React's internals and the problems concurrent rendering introduces.
Starting with useId -- this one is actually pretty simple in concept but solves a surprisingly tricky problem. In web development, you often need unique IDs to connect elements together. A form label needs an htmlFor that matches its input's id. ARIA attributes like aria-describedby reference IDs of other elements. You need these IDs to be unique so they don't accidentally collide with IDs from other components rendered on the same page.
So you might think: I'll just use a counter. Let globalCounter = 0, and each component increments it to get a unique ID. Or you might reach for Math.random() or crypto.randomUUID(). These approaches work fine in a client-only app, but they completely break with server-side rendering.
Here's why. When you use SSR, the server renders your component tree to HTML and sends it to the browser. Then React hydrates that HTML on the client -- it attaches event listeners and state to the existing DOM. During hydration, React compares what the server rendered with what the client would render. If the server generated id='input-1' using a counter, but the client generates id='input-3' because the counter is in a different state, React detects a hydration mismatch and either shows a warning or re-renders from scratch, wiping out the performance benefit of SSR.
Math.random() is even worse -- it's guaranteed to produce different values on server and client.
useId solves this by generating IDs that are deterministic and stable across server and client. React uses the component's position in the tree (its fiber path) to generate the ID, so the same component in the same position always gets the same ID, regardless of whether it's on the server or client. The IDs look like ':r0:', ':r1:', ':r2:' -- they're not pretty, but they're stable.
You can derive multiple IDs from a single useId call by appending suffixes. If const id = useId() gives you ':r0:', then `${id}-input` gives you ':r0:-input' and `${id}-error` gives you ':r0:-error'. This is handy when a single form field needs an input ID, an error message ID, and a hint text ID that all reference each other through ARIA attributes.
One thing to note: because useId IDs contain colons, you can't use them as CSS selectors directly (colons have special meaning in CSS). But that's fine -- you shouldn't be using generated IDs for styling anyway.
Now, useSyncExternalStore -- this one is more complex and solves a problem that most developers don't even know exists until they hit it: UI tearing.
To understand tearing, you need to understand concurrent rendering. In React 18 with concurrent features enabled, React can pause a render in the middle, do something else (handle user input, for example), and then come back and finish the render. This is great for responsiveness, but it creates a problem with external data stores.
Imagine a Redux store that holds a counter value of 5. React starts rendering your component tree. Component A reads the store and sees 5. Then React pauses the render to handle a user click. That click dispatches an action that changes the counter to 6. React resumes rendering. Component B reads the store and sees 6. Now you have a torn UI -- two components on screen that were rendered from the same render pass but show different data. Component A shows 5 and Component B shows 6.
useSyncExternalStore prevents this by synchronizing React's rendering with an external store. It takes three arguments: a subscribe function (how to listen for changes), a getSnapshot function (how to read the current value), and an optional getServerSnapshot (what to return during SSR when the store doesn't exist yet).
The key insight is that React can use the snapshot to detect if the store changed mid-render. If it did, React synchronously re-renders to ensure consistency -- no tearing.
You'll rarely call useSyncExternalStore directly in application code. But you use it indirectly all the time. Redux's useSelector is built on top of it. Zustand uses it internally. Any state management library that works correctly with React 18 concurrent features is using useSyncExternalStore under the hood.
Where you might use it directly is for subscribing to browser APIs. A useWindowWidth hook that listens to the resize event and returns window.innerWidth is a perfect example. The subscribe function adds a resize listener. The getSnapshot returns window.innerWidth. The getServerSnapshot returns a sensible default like 1024 because window doesn't exist on the server.
Before useSyncExternalStore, you'd do this with useState + useEffect -- set up state, add an event listener in the effect, update state in the listener. That works, but it's not safe for concurrent rendering (it can tear) and it has an unnecessary render cycle (mount with default, then update with real value). useSyncExternalStore is the correct, concurrent-safe way to do it.
import { useId } from 'react';
// PROBLEM: Fragile id counter
let count = 0;
function InputOld({ label }) {
const id = `input-${++count}`; // Different on server vs client!
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
// SOLUTION: useId generates stable SSR-safe IDs
function Input({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
// Multiple IDs from one call
function FormField({ label }) {
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
aria-describedby={`${hintId} ${errorId}`}
/>
<span id={hintId}>Enter your name</span>
</div>
);
}
useId generates IDs like :r0:, :r1: - stable across server and client. You can derive multiple IDs from one call by appending suffixes.
import { useSyncExternalStore } from 'react';
// Custom hook for window width
function useWindowWidth() {
return useSyncExternalStore(
// Subscribe: called with a callback to invoke on changes
(onStoreChange) => {
window.addEventListener('resize', onStoreChange);
return () => window.removeEventListener('resize', onStoreChange);
},
// Get current snapshot (client)
() => window.innerWidth,
// Get server snapshot (SSR fallback)
() => 1024
);
}
function ResponsiveComponent() {
const width = useWindowWidth();
return <div>{width > 768 ? 'Desktop' : 'Mobile'}</div>;
}
// Redux uses useSyncExternalStore internally (simplified)
function useSelector(selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(getServerState())
);
}
useSyncExternalStore prevents UI tearing in concurrent mode by ensuring the subscription, snapshot, and server snapshot are all consistent within a single render.
useId, useSyncExternalStore, SSR, External Store, Tearing