React Hooks Part 6: useId, useTransition, and useDeferredValue
React 18 shipped three new hooks that tackle different problems: useId for stable unique IDs, useTransition for non-blocking UI updates, and useDeferredValue for deprioritising expensive renders.
React Hooks: A Complete Guide
A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.
- 4 React Hooks Part 4: useCallback, useMemo, and Custom Hooks
- 5 React Hooks Part 5: Sharing State with useContext
- 6 React Hooks Part 6: useId, useTransition, and useDeferredValue
- 7 React Hooks Part 7: useImperativeHandle and useLayoutEffect
- 8 React Hooks Part 8: Rules, Anti-Patterns, and the ESLint Plugin
Table of Contents
React 18 and concurrent features
React 18 introduced concurrent rendering — the ability to interrupt, pause, and resume renders. Three new hooks expose this system to user-land code.
useId — stable, unique IDs
Generating unique IDs in React used to be tricky: Math.random() breaks server-side rendering because the server and client produce different values, causing hydration mismatches.
useId generates an ID that is stable across server and client.
import { useId } from 'react'
function PasswordField() {
const id = useId()
return (
<div>
<label htmlFor={id}>Password</label>
<input id={id} type="password" />
</div>
)
}
If you need multiple related IDs in one component, extend with a suffix:
function Form() {
const id = useId()
return (
<form>
<label htmlFor={`${id}-name`}>Name</label>
<input id={`${id}-name`} />
<label htmlFor={`${id}-email`}>Email</label>
<input id={`${id}-email`} />
</form>
)
}
Never use useId for keys in lists — it's for DOM accessibility attributes only.
useTransition — non-blocking state updates
Some state updates are urgent (typing, clicking) and some are not (filtering a large list, navigating to a new page). useTransition lets you tell React: "this update is low priority — don't block the UI for it."
import { useState, useTransition } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleChange = e => {
const q = e.target.value
setQuery(q) // urgent: update input immediately
startTransition(() => {
setResults(heavyFilter(q)) // non-urgent: can be interrupted
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating…</p>}
<ResultList results={results} />
</>
)
}
While the transition is pending, React can still respond to new user input — it will abort the in-progress render and start fresh with the newer value.
When to use
- Tab switching that renders large content
- Filtering or sorting large data sets
- Route navigations (React Router uses this internally in v6.4+)
useDeferredValue — defer expensive renders
useDeferredValue is the value-level counterpart to useTransition. Instead of wrapping a state setter, you wrap a value and tell React it's okay to use a stale copy while computing the fresh one.
import { useState, useDeferredValue, memo } from 'react'
const HeavyList = memo(function HeavyList({ query }) {
// Expensive render
const items = computeItems(query)
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
})
function App() {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<HeavyList query={deferredQuery} />
</>
)
}
The input (query) updates instantly on every keystroke. HeavyList receives deferredQuery, which lags behind until React has time to re-render it without blocking.
useTransition vs useDeferredValue
| useTransition | useDeferredValue | |
|---|---|---|
| You control | the setter call | the value |
| Use when | you own the state update | you receive the value as a prop |
| Pending indicator | isPending flag |
compare value to deferred value |
// Show stale indicator
const isStale = query !== deferredQuery
<HeavyList query={deferredQuery} style={{ opacity: isStale ? 0.5 : 1 }} />
Key takeaways
useId— stable IDs for accessibility, safe in SSR.useTransition— mark a setter call as non-urgent; get anisPendingflag.useDeferredValue— defer a value downstream without touching the setter.- All three are opt-in — you only add them where the UX improvement is noticeable.