
There's a handful of React hooks that almost nobody uses. They're in the docs, they ship with React, but they never show up in tutorials or code reviews.
useId. useDebugValue. useSyncExternalStore. useDeferredValue. useTransition.
You've probably scrolled past them. Maybe you read the description and thought "I'll learn this when I need it."
Here's the thing: you probably need them now. You just don't know it yet.
useId: the accessibility hook
You're building a form. You need to connect labels to inputs:
function EmailField() {
return (
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</div>
);
}
Simple. Until you render two EmailField components on the same page. Now you have two elements with id="email". Invalid HTML. Broken accessibility.
The old fix:
function EmailField() {
const [id] = useState(() => `email-${Math.random().toString(36).slice(2)}`);
return (
<div>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</div>
);
}
This works... until server-side rendering. The server generates one ID, the client generates another. Hydration mismatch. React yells at you.
useId to the rescue
function EmailField() {
const id = useId();
return (
<div>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</div>
);
}
useId generates a unique ID that's stable across server and client. No hydration mismatch. No duplicate IDs. It just works.
Multiple IDs from one call
Need several related IDs? Use a single useId as a prefix:
function FormField({ label }) {
const id = useId();
return (
<div>
<label htmlFor={`${id}-input`}>{label}</label>
<input id={`${id}-input`} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>This field is required</p>
</div>
);
}
One hook call, three connected elements. Clean and accessible.
When to use useId
- Form labels and inputs
- ARIA attributes (aria-labelledby, aria-describedby)
- Any time you need a unique ID in a reusable component
When NOT to use useId
- Keys in lists (use data IDs instead)
- CSS selectors (don't rely on generated IDs for styling)
useDebugValue: make React DevTools useful
You've built a custom hook. It works great. But when you inspect it in React DevTools, you see... nothing useful.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
In DevTools, you see: OnlineStatus: true
Not super helpful. Is that good? Bad?
Add context with useDebugValue
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useDebugValue(isOnline ? '🟢 Online' : '🔴 Offline');
// ... rest of the hook
return isOnline;
}
Now DevTools shows: OnlineStatus: "🟢 Online"
Instantly clear.
Defer expensive formatting
If your debug value requires expensive computation, defer it:
function useDataFetch(url) {
const [data, setData] = useState(null);
// Only compute the debug string when DevTools is open
useDebugValue(data, (data) => {
if (!data) return 'Loading...';
return `Loaded ${data.items.length} items at ${new Date().toLocaleTimeString()}`;
});
// ... fetch logic
return data;
}
The second argument is a formatter function. It only runs when you're actually inspecting the component in DevTools.
When to use useDebugValue
- Custom hooks in shared libraries
- Complex hooks where the internal state isn't obvious
- Debugging during development
When NOT to use useDebugValue
- Every custom hook (overkill)
- Production builds (it's a no-op in production anyway)
- Simple hooks where the return value is self-explanatory
useSyncExternalStore: connect to the outside world
React manages its own state. But sometimes you need to sync with external data:
- Browser APIs (online status, window size, media queries)
- Third-party state libraries (Redux, Zustand without their React bindings)
- WebSocket connections
- LocalStorage
The naive approach:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
This works for simple cases. But it has problems with concurrent rendering. React might "tear" your UI: some components see the old value, others see the new value.
useSyncExternalStore does it right
function useWindowWidth() {
return useSyncExternalStore(
// subscribe: how to listen for changes
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
// getSnapshot: how to get current value
() => window.innerWidth
);
}
useSyncExternalStore guarantees consistency. All components see the same value during a render.
Real-world example: online status
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // Server snapshot: assume online
);
}
The third argument is for SSR. Since there's no navigator on the server, we provide a fallback.
Real-world example: media queries
function useMediaQuery(query) {
return useSyncExternalStore(
(callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
},
() => window.matchMedia(query).matches,
() => false // Server fallback
);
}
// Usage
function App() {
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileNav /> : <DesktopNav />;
}
When to use useSyncExternalStore
- Browser APIs that change over time
- External state management without React bindings
- Any subscription-based data source
When NOT to use useSyncExternalStore
- React state (use useState/useReducer)
- Data fetching (use useEffect or a library like TanStack Query)
- One-time reads (no subscription needed)
useDeferredValue: keep the UI responsive
You have a search input that filters a huge list:
function Search() {
const [query, setQuery] = useState('');
const filteredItems = filterItems(items, query); // Expensive!
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<List items={filteredItems} />
</>
);
}
Every keystroke triggers filtering. With 10,000 items, the input feels sluggish.
Defer the expensive part
function Search() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// Filtering uses the deferred value
const filteredItems = useMemo(
() => filterItems(items, deferredQuery),
[deferredQuery]
);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<List items={filteredItems} />
</>
);
}
Now the input updates immediately (using query), while the list updates with a slight delay (using deferredQuery). The UI stays responsive.
Show stale content while updating
You can detect when the deferred value is stale:
function Search() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const filteredItems = useMemo(
() => filterItems(items, deferredQuery),
[deferredQuery]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<List items={filteredItems} />
</div>
</>
);
}
The list dims while updating, giving visual feedback.
When to use useDeferredValue
- Expensive renders based on user input
- When you can't control the source of the value
- Showing stale content is acceptable
useTransition: take control of updates
useDeferredValue defers a value. useTransition defers an update.
function TabContainer() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // This update is "low priority"
});
}
return (
<>
<TabButtons onSelect={selectTab} isPending={isPending} />
<TabContent tab={tab} />
</>
);
}
When you click a tab, React marks it as a "transition." If the user clicks another tab before the first one renders, React abandons the first render and starts the second.
The difference from useDeferredValue
| useDeferredValue | useTransition |
|---|---|
| Defers a value | Defers a state update |
| You don't control the source | You control the update |
| Returns deferred value | Returns [isPending, startTransition] |
Real-world example: navigation
function Router() {
const [page, setPage] = useState('/home');
const [isPending, startTransition] = useTransition();
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
return (
<>
<Nav onNavigate={navigate} />
{isPending && <LoadingBar />}
<Page url={page} />
</>
);
}
The nav stays interactive while the page loads. The loading bar shows progress.
When to use useTransition
- Tab switching
- Navigation
- Any update where interactivity > immediate render
The cheat sheet
| Hook | Purpose | Use when |
|---|---|---|
useId | Generate unique, stable IDs | Form accessibility, ARIA attributes |
useDebugValue | Label hooks in DevTools | Debugging custom hooks |
useSyncExternalStore | Subscribe to external data | Browser APIs, external state |
useDeferredValue | Defer a value | Can't control the update source |
useTransition | Defer an update | You control the state update |
What's next?
You now know every hook that ships with React 18.
But React 19 changes the game. New hooks for forms, optimistic updates, and async state. Server components and actions. A whole new mental model.
In the final article, we'll explore React 19's new hooks and what they mean for the future.
Continue to part 5: React 19's new era →
Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.