Back to blog

The hooks you forgot existed

useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition. You've probably scrolled past them. You probably need them now.

January 30, 202614 min read
ReactHooksuseIduseTransitionuseDeferredValue
The hooks you forgot existed
Hidden Gems - useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition
Hidden Gems - useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition

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

useDeferredValueuseTransition
Defers a valueDefers a state update
You don't control the sourceYou control the update
Returns deferred valueReturns [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

HookPurposeUse when
useIdGenerate unique, stable IDsForm accessibility, ARIA attributes
useDebugValueLabel hooks in DevToolsDebugging custom hooks
useSyncExternalStoreSubscribe to external dataBrowser APIs, external state
useDeferredValueDefer a valueCan't control the update source
useTransitionDefer an updateYou 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.