Module: React and Ecosystem
React and Ecosystem·103·8 MIN READ

103: Custom Hooks and Advanced Built-in Hooks

TOPICS COVERED: Custom Hooks and Advanced Built-in Hooks

Learning objectives

You will learn to:

  • extract reusable stateful logic into custom Hooks;
  • keep custom Hooks focused on behavior rather than hidden UI;
  • design Hook inputs and return values;
  • expose debugging information with useDebugValue;
  • subscribe to external stores with useSyncExternalStore;
  • create stable accessible IDs with useId;
  • understand where useImperativeHandle and layout hooks fit;
  • avoid “utility Hook” over-abstraction.

What a custom Hook actually reuses

A custom Hook reuses stateful logic, not state itself.

Two components calling the same Hook get independent state unless the Hook subscribes to the same external source.

jsx
function useDisclosure(initialOpen = false) {
  const [open, setOpen] = useState(initialOpen);

  function toggle() {
    setOpen((current) => !current);
  }

  return {
    open,
    setOpen,
    toggle,
  };
}

Use it twice:

jsx
const menu = useDisclosure();
const help = useDisclosure();

These are independent.

Rules of Hooks

Hooks are special because React associates them with a component's render order.

Call Hooks:

  • at the top level of a component;
  • at the top level of another Hook.

Do not call them:

  • inside conditions;
  • inside loops;
  • after an early return that is not always taken;
  • inside ordinary event handlers.

Wrong:

jsx
if (loggedIn) {
  const [profile, setProfile] = useState(null);
}

React must be able to match Hook calls between renders.

Example: reusable request state

For learning purposes:

jsx
function useResource(url) {
  const [state, setState] = useState({
    status: 'pending',
    data: null,
    error: null,
  });

  useEffect(() => {
    const controller = new AbortController();

    setState({
      status: 'pending',
      data: null,
      error: null,
    });

    fetch(url, {
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) {
          throw new Error(
            `HTTP ${response.status}`,
          );
        }

        return response.json();
      })
      .then((data) => {
        setState({
          status: 'success',
          data,
          error: null,
        });
      })
      .catch((error) => {
        if (error.name === 'AbortError') {
          return;
        }

        setState({
          status: 'error',
          data: null,
          error,
        });
      });

    return () => {
      controller.abort();
    };
  }, [url]);

  return state;
}

This is useful to understand custom Hooks, but later TanStack Query v5 is preferred for server state.

Do not rebuild a query-cache library as an exercise in abstraction.

Custom Hook API design

A Hook should reveal its contract clearly.

Less clear:

jsx
const result = useTaskStuff(id);

Better when the returned responsibilities are obvious:

jsx
const {
  task,
  saveTask,
  isSaving,
  saveError,
} = useTaskEditor(id);

Avoid returning a huge bag of unrelated values that effectively becomes a hidden framework.

Hooks should not hide surprising behavior

A Hook named:

jsx
useTaskTitle()

should not silently:

  • write to localStorage;
  • update document.title;
  • open a WebSocket;
  • register global shortcuts;
  • redirect the router.

Names and documentation should make side effects visible.

useId

useId creates a stable ID suitable for accessibility relationships:

jsx
function Field({
  label,
  error,
  ...props
}) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>
        {label}
      </label>

      <input
        id={id}
        aria-invalid={Boolean(error)}
        aria-describedby={
          error ? errorId : undefined
        }
        {...props}
      />

      {error && (
        <p id={errorId} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Do not use useId for list keys.

Keys identify data records and should come from the data.

useDebugValue

Library and shared Hook authors can expose useful labels in React DevTools:

jsx
function useOnlineStatus() {
  const online =
    useSyncExternalStore(
      subscribe,
      getSnapshot,
      getServerSnapshot,
    );

  useDebugValue(
    online ? 'Online' : 'Offline',
  );

  return online;
}

Do not add useDebugValue to every tiny local Hook.

useSyncExternalStore

React provides a dedicated primitive for subscribing to external stores.

Examples:

  • browser online status;
  • media-query store;
  • custom client-side store;
  • library subscription;
  • data source outside React.
jsx
function subscribe(callback) {
  window.addEventListener(
    'online',
    callback,
  );
  window.addEventListener(
    'offline',
    callback,
  );

  return () => {
    window.removeEventListener(
      'online',
      callback,
    );
    window.removeEventListener(
      'offline',
      callback,
    );
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true;
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
}

The snapshot returned by getSnapshot must be stable when nothing changed.

Do not return a brand-new object every time unless it is cached.

Why not just use Effect + setState?

A manual subscription can work, but useSyncExternalStore gives React a protocol designed for external stores and server rendering.

It avoids important tearing and synchronization problems library authors would otherwise need to solve.

Custom Hook with an Effect Event

React 19.2 Effect Events can be used inside custom Hooks.

jsx
function useChatRoom({
  roomId,
  onMessage,
}) {
  const handleMessage =
    useEffectEvent(onMessage);

  useEffect(() => {
    const room = connect(roomId);

    room.on('message', (message) => {
      handleMessage(message);
    });

    return () => room.disconnect();
  }, [roomId]);
}

Now the connection follows roomId, while the callback always sees the latest values.

Advanced Hook inventory

You should know the role of these Hooks even if you do not use all of them daily:

HookMain purpose
useStatelocal state
useReducerexplicit state transitions
useContextread context
useRefmutable non-render value / DOM
useEffectexternal synchronization
useEffectEventnon-reactive event logic inside Effects
useLayoutEffectpre-paint layout synchronization
useInsertionEffectstyle-library insertion
useImperativeHandleconstrained ref API
useIdaccessibility-safe IDs
useSyncExternalStoreexternal subscription
useDebugValueDevTools label for Hooks
useMemocache calculation when justified
useCallbackcache function identity when justified
useTransitionmark non-urgent update
useDeferredValuedefer non-urgent consumer value
useActionStatestate from an Action
useOptimisticoptimistic temporary state

Performance and Action hooks are covered deeply later.

Common mistakes

Hook for every function

A function is only a Hook if it calls Hooks or intentionally participates in Hook composition.

Do not rename normal utilities with use.

Generic useFetch

A generic fetch Hook quickly grows into retries, caching, dedupe, invalidation, pagination, cancellation, polling, and mutations.

Use TanStack Query v5 instead of rebuilding it.

Hook returning JSX

Custom Hooks usually return data and behavior. Components return JSX.

If a Hook becomes a hidden component tree, reconsider the boundary.

External snapshot instability

Wrong:

jsx
function getSnapshot() {
  return {
    online: navigator.onLine,
  };
}

This returns a new object every time.

Exercises

  1. Build useDisclosure.
  2. Build useOnlineStatus with useSyncExternalStore.
  3. Create a reusable form field using useId.
  4. Add useDebugValue to a shared Hook.
  5. Refactor a Hook that hides too many responsibilities into two Hooks.
  6. Explain why a custom useFetch is not a replacement for TanStack Query.

Exit questions

  1. What is shared when two components call the same custom Hook?
  2. Why must Hook call order remain stable?
  3. What problem does useSyncExternalStore solve?
  4. Why should useId not be used as a list key?
  5. When is useDebugValue useful?
  6. What makes a custom Hook API understandable?

Official references


Deep dive: custom Hooks are behavior modules with lifecycle contracts

A strong custom Hook should let a component say what it needs without exposing low-level synchronization.

Example:

jsx
function useKeyboardShortcut(shortcut, onTrigger) {
  const onTriggerEvent = useEffectEvent(onTrigger);

  useEffect(() => {
    function handleKeyDown(event) {
      if (matchesShortcut(event, shortcut)) {
        event.preventDefault();
        onTriggerEvent();
      }
    }

    window.addEventListener('keydown', handleKeyDown);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, [shortcut]);
}

Consumer:

jsx
useKeyboardShortcut('Ctrl+K', () => {
  setCommandPaletteOpen(true);
});

The component does not manage window subscription cleanup.

The Hook name communicates a global interaction responsibility.

Hook input stability

A Hook API can accidentally make dependencies unstable.

Weak:

jsx
useChat({
  roomId,
  options: {
    reconnect: true,
  },
});

If the Hook uses the whole options object as an Effect dependency, the caller creates a new object every render.

Better Hook design may accept primitives:

jsx
useChat({
  roomId,
  reconnect: true,
});

or the Hook can extract meaningful primitive fields.

Do not force callers to useMemo every config object just to keep your Hook from resubscribing.

Hook return API

Boolean bag:

jsx
const {
  isOpen,
  open,
  close,
  toggle,
  setOpen,
} = useDisclosure();

may be acceptable.

But avoid hidden overlap:

jsx
const {
  state,
  data,
  value,
  result,
  current,
  thing,
} = useSomething();

Name return values according to domain behavior.

Reducer inside Hook

jsx
function useTaskSelection() {
  const [state, dispatch] = useReducer(selectionReducer, {
    selectedIds: [],
  });

  return {
    selectedIds: state.selectedIds,
    toggle(id) {
      dispatch({
        type: 'selection/toggled',
        id,
      });
    },
    clear() {
      dispatch({
        type: 'selection/cleared',
      });
    },
  };
}

This hides transition mechanics while keeping the public API domain-oriented.

useSyncExternalStore deeper example: media query

jsx
function subscribeToMediaQuery(query, callback) {
  const media = window.matchMedia(query);
  media.addEventListener('change', callback);

  return () => {
    media.removeEventListener('change', callback);
  };
}

function createMediaQueryStore(query) {
  return {
    subscribe(callback) {
      return subscribeToMediaQuery(query, callback);
    },

    getSnapshot() {
      return window.matchMedia(query).matches;
    },

    getServerSnapshot() {
      return false;
    },
  };
}

In practice, create the store outside render or memoize the infrastructure appropriately so subscriptions do not churn.

This demonstrates an external value React does not own.

useId and hydration stability

Do not generate IDs with:

jsx
Math.random()
crypto.randomUUID()

during render for label relationships.

Server/client could generate different IDs, causing hydration mismatch.

useId produces IDs designed to work with React's rendering/hydration model:

jsx
const id = useId();

Use it for:

  • label ↔ input;
  • help text;
  • description IDs;
  • ARIA relationships.

Not for database IDs or list keys.

Hook composition

A feature Hook can compose lower-level Hooks:

jsx
function useTaskEditor(task) {
  const form = useTaskForm(task);
  const online = useOnlineStatus();

  const save = useCallback(async () => {
    if (!online) {
      throw new Error('Offline');
    }

    return form.submit();
  }, [online, form]);

  return {
    ...form,
    online,
    save,
  };
}

But be careful: spreading large Hook objects can create unstable object identities and blurred responsibilities.

Sometimes returning structured pieces is clearer.

Do not over-abstract one-off code

If only one component needs:

jsx
const [open, setOpen] = useState(false);

creating:

jsx
useSettingsSidebarDisclosureStateManager()

adds indirection, not reuse.

Extract when:

  • behavior repeats;
  • synchronization is non-trivial;
  • the component becomes hard to read;
  • lifecycle should be hidden behind a tested contract.

Hook testing philosophy

Prefer testing a Hook through a realistic component when possible because Hooks exist inside React lifecycle.

For a complex shared Hook, renderHook can be useful:

jsx
const { result } = renderHook(() => useDisclosure());

act(() => {
  result.current.open();
});

expect(result.current.isOpen).toBe(true);

But do not test React itself. Test your behavior and edge cases.

Hook library boundaries

A shared Hook should not silently import feature-specific APIs.

Bad:

text
shared/hooks/useOnlineStatus
→ imports taskApi

Keep dependency direction clean.

Feature Hook:

text
features/tasks/useTaskRealtime

can import task-domain infrastructure.

Hook error handling

If a Hook requires a provider:

jsx
function useAuth() {
  const value = useContext(AuthContext);

  if (value === null) {
    throw new Error('useAuth must be used inside AuthProvider');
  }

  return value;
}

Failing loudly near development time is better than returning fake fallback auth state.

Advanced built-in Hook distinctions

useId

identity for accessibility/hydration.

useDebugValue

DevTools labeling for reusable Hooks.

useSyncExternalStore

external state subscription.

useImperativeHandle

custom imperative ref surface.

useLayoutEffect

pre-paint synchronization.

useInsertionEffect

CSS-in-JS library infrastructure.

useEffectEvent

latest event logic invoked from Effects.

These Hooks solve specific escape-hatch/library problems. They should not dominate everyday component code.

Failure clinic

Hook calls another Hook conditionally

Breaks Rules of Hooks.

Hook recreates subscription every render

Often unstable object/function input.

getSnapshot returns new object each call

Can cause infinite/inconsistent external store updates.

Generic useApi

If it grows caching, mutations, retries, invalidation, polling, pagination, it is becoming a poor reimplementation of TanStack Query.

Exercises

  1. Build useKeyboardShortcut using useEffectEvent.
  2. Build a media-query external store using useSyncExternalStore.
  3. Create an accessible field Hook around useId.
  4. Refactor a Hook API to eliminate unstable config object churn.
  5. Identify a one-off Hook abstraction that should be deleted.
  6. Test one custom Hook through a component and through renderHook, then compare.

Mastery check

Explain:

  • what a custom Hook reuses;
  • Hook API stability;
  • external store snapshots;
  • hydration-safe IDs;
  • when extracting a Hook improves design;
  • why shared Hook dependency direction matters.

Production case study: a custom Hook that coordinates browser state without hiding business state

Build:

jsx
function useDocumentVisibility() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
}

Implementation:

jsx
function subscribe(callback) {
  document.addEventListener('visibilitychange', callback);

  return () => {
    document.removeEventListener('visibilitychange', callback);
  };
}

function getSnapshot() {
  return document.visibilityState;
}

function getServerSnapshot() {
  return 'visible';
}

Consumer:

jsx
function QueueStatus() {
  const visibility = useDocumentVisibility();

  return (
    <p>
      Window: {visibility}
    </p>
  );
}

This Hook cleanly wraps a browser external store.

Do not turn it into:

jsx
useDocumentVisibilityAndRefreshOrdersAndTrackAnalyticsAndPauseVideo()

Keep browser observation reusable.

Business behavior composes it:

jsx
const visibility = useDocumentVisibility();

useEffect(() => {
  if (visibility === 'visible') {
    // only if Query's own focus behavior does not already solve this
  }
}, [visibility]);

Before adding that Effect, remember TanStack Query already has focus/refetch policies. Do not duplicate library behavior with custom Hooks.


Additional depth: building robust Hook contracts

Stable service dependency

Rather than a Hook importing one hard-coded global API:

jsx
function useTaskExport() {
  import ...
}

a provider can supply a service:

jsx
const ServicesContext = createContext(null);

function useServices() {
  const services = useContext(ServicesContext);

  if (!services) {
    throw new Error('ServicesProvider missing');
  }

  return services;
}

Feature Hook:

jsx
function useTaskExport() {
  const { taskExporter } = useServices();

  return useCallback(
    (ids) => taskExporter.export(ids),
    [taskExporter],
  );
}

This can improve testability for large applications, though it is unnecessary ceremony for small apps.

Hook return stability

If a library consumer relies on referential equality, returning a new object every render matters:

jsx
return {
  open,
  toggle,
};

For ordinary application components this is usually fine.

Do not automatically:

jsx
return useMemo(() => ({ open, toggle }), [open, toggle]);

unless the API/consumer actually requires stable identity or compiler/build strategy indicates it.

Hook naming and side effects

Names should expose side effects.

Compare:

text
useUser

versus:

text
useUserPresenceSubscription

If a Hook opens realtime connections, its name/documentation should make lifecycle cost discoverable.

Hook composition ownership

A custom Hook should generally have one lifecycle story.

If it:

text
reads URL
writes localStorage
opens WebSocket
fetches API
manages form draft

it is probably a feature controller hiding too many owners.

Split around responsibility, then compose at the feature component/provider level.