background-shape
React 18 New Hooks, useId, useSyncExternalStore, and When You'll Actually Use Them
April 11, 2022 · 5 min read · by Muhammad Amal programming

TL;DRuseId is for SSR-safe unique IDs (a11y); use it. useSyncExternalStore is for state library authors integrating with React 18 (Redux, Zustand); you probably don’t write it yourself. useInsertionEffect is 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 key in lists. useId is 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.