FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·103·16 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.

The through-line is responsibility. A good Hook gives a component a useful behavior while keeping the lifecycle details, subscriptions, or state transitions in a boundary that can be understood and tested. The goal is not to wrap every function in a Hook. It is to extract logic when doing so makes ownership clearer.

What a custom Hook actually reuses

When developers first encounter custom Hooks, they sometimes describe them as a way to share state. That is not quite right. A custom Hook reuses stateful logic, not the state instance itself.

Each component that calls the Hook receives its own Hook state. The calls share an implementation and behavior, but they remain independent unless the Hook explicitly subscribes to a common 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();

menu.open and help.open are separate values. Changing one does not change the other. What is shared is the implementation of the disclosure behavior. That distinction becomes important when deciding whether a problem calls for a custom Hook or a shared store.

Rules of Hooks

Hooks are special because React associates each call with its position in a component's render order. React does not identify a state Hook by the variable name you give its result. It relies on the sequence of calls remaining consistent from render to render.

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);
}

On one render this code may call useState, and on another it may not. That changes the order React uses to associate Hook state with calls. React must be able to match Hook calls between renders, so move the Hook to the component's top level and put the condition around the behavior that uses its result.

Example: reusable request state

For learning purposes, a custom Hook can also package the state machine around a request:

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 example gives the learner a compact way to see several Hook concerns together: the URL controls the Effect dependency, cleanup aborts work for an old URL, and the returned state describes pending, success, and error outcomes. It is useful to understand how custom Hooks are built, but it is not a recommendation to use this as a production query layer.

Later, TanStack Query v5 is preferred for server state. A real server-state solution also has to make decisions about caching, retries, deduplication, invalidation, pagination, polling, and mutations. Do not rebuild a query-cache library as an exercise in abstraction.

Custom Hook API design

A Hook should reveal its contract clearly. The caller should be able to infer what the Hook owns and what each returned value means without opening several implementation files.

Less clear:

jsx
const result = useTaskStuff(id);

Better when the returned responsibilities are obvious:

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

The second API names the domain behavior directly. Avoid returning a huge bag of unrelated values that effectively becomes a hidden framework. A large return object makes it difficult to tell which values belong together, and it encourages callers to depend on implementation details that the Hook may later need to change.

Hooks should not hide surprising behavior

Naming is part of the API. A Hook named:

jsx
useTaskTitle()

should not silently:

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

Those operations have different ownership and lifecycle costs. Names and documentation should make side effects visible. If a Hook really coordinates a subscription or browser effect, say so in the name or in the documented contract rather than making callers discover it by accident.

useId

useId creates a stable ID suitable for accessibility relationships. The important use case is connecting an element such as a label or description to the control it describes:

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>
  );
}

The same generated base ID is used for the input and its conditional error message, so the relationship remains correct when the error appears or disappears. Do not use useId for list keys. Keys identify data records and should come from the data; an accessibility ID is not a record identity and is not a substitute for one.

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;
}

This does not change the Hook's runtime result. It adds a useful description for someone inspecting the Hook in DevTools. Do not add useDebugValue to every tiny local Hook; the extra label is most valuable when a Hook is shared, non-obvious, or likely to be inspected while diagnosing application behavior.

useSyncExternalStore

React provides a dedicated primitive for subscribing to external stores. An external store is state whose source of truth is outside React's own state system, such as:

  • 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 subscription tells React how to start and stop listening. The snapshot tells React what the current value is, and the server snapshot supplies a value during server rendering. The snapshot returned by getSnapshot must be stable when nothing changed. Do not return a brand-new object every time unless it is cached, because React needs to recognize that the external value is unchanged.

The cleanup function is part of the contract, too. It must remove the same listener or subscription that was installed for that callback. Missing cleanup can leave stale listeners active after a component is gone, while an unstable subscription setup can repeatedly attach and detach listeners during otherwise unrelated renders.

Why not just use Effect + setState?

A manual subscription can work, especially for a narrowly scoped browser integration, but useSyncExternalStore gives React a protocol designed for external stores and server rendering. It describes the relationship among subscribing, reading the current snapshot, and checking for changes.

That protocol avoids important tearing and synchronization problems that library authors would otherwise need to solve themselves. The choice is therefore not just stylistic. If the state truly lives outside React and must be observed safely, use the primitive intended for that boundary.

Custom Hook with an Effect Event

React 19.2 Effect Events can be used inside custom Hooks. They are useful when an Effect should react to one dependency, such as a room ID, while invoking the latest version of a callback without making that callback itself restart the Effect.

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. This prevents a changing callback identity from needlessly disconnecting and reconnecting the room, while still avoiding a stale callback closure.

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. For this lesson, the useful distinction is that most of these Hooks solve a specific rendering, synchronization, or library-integration problem. Knowing their purpose is more valuable than using them by reflex.

Two entries deserve a practical boundary here. useImperativeHandle is for exposing a deliberately small imperative API through a ref; it should not become a way for a parent to reach into every detail of a child. useLayoutEffect runs before the browser paints and is appropriate when a measurement or DOM adjustment must be synchronized before the user sees the result. Because it can delay painting, ordinary useEffect remains the default unless pre-paint timing is part of the requirement. useInsertionEffect is even more specialized and is primarily infrastructure for style libraries.

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. The naming convention communicates that the function obeys the Rules of Hooks and participates in React lifecycle behavior. Misusing it confuses readers and can mislead linting or future maintainers.

Generic useFetch

A generic fetch Hook quickly grows into retries, caching, dedupe, invalidation, pagination, cancellation, polling, and mutations. Each feature introduces behavior that must remain correct across loading transitions, errors, unmounts, and concurrent requests.

Use TanStack Query v5 instead of rebuilding it. A small educational request Hook is useful for learning; a production server-state layer deserves the capabilities and tested semantics of a dedicated library.

Hook returning JSX

Custom Hooks usually return data and behavior. Components return JSX. If a Hook becomes a hidden component tree, reconsider the boundary. The caller should normally decide how the behavior is rendered, while the Hook owns reusable state transitions or synchronization.

External snapshot instability

Wrong:

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

This returns a new object every time. Even when navigator.onLine has not changed, the object reference is different, so React cannot treat the snapshot as unchanged. Return a primitive, reuse a cached object, or otherwise provide a stable snapshot representation.

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. The Hook is a behavior module: it owns a lifecycle contract, and the component consumes that contract.

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 expresses the feature it wants and does not manage window subscription cleanup. The Hook name communicates a global interaction responsibility, so a reader can reasonably expect it to attach a listener and clean it up when the component's lifecycle ends.

Hook input stability

A Hook API can accidentally make dependencies unstable. Consider:

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. The Hook may then resubscribe even though the meaningful settings did not change. That is a design problem at the boundary, not a reason to make every caller reach for useMemo.

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. If a configuration object is genuinely the API, document its identity requirements and compare or normalize the fields that actually control the lifecycle.

Hook return API

Boolean bag:

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

may be acceptable. The names describe one cohesive behavior and make the caller readable.

But avoid hidden overlap:

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

This API forces the consumer to inspect the implementation before it can know which value to use. Name return values according to domain behavior. A clear return API is not cosmetic; it limits accidental coupling and makes the Hook easier to evolve.

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. The consumer does not need to know the reducer action names, but the Hook still exposes operations that describe the task-selection behavior. That is a useful place for a reducer: it keeps a set of related transitions explicit without leaking the state machine's plumbing.

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;
    },
  };
}

Here the query is the identity of the external value being observed. The browser owns whether the media query matches; React observes that value through the store contract. 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, not a replacement for ordinary local UI state.

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. Even apart from hydration, generating a new ID during render makes the relationship unstable across renders.

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. Those represent application data and must have a source of identity appropriate to that data.

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,
  };
}

This lets the feature-level API combine form behavior with the browser's connectivity state. But be careful: spreading large Hook objects can create unstable object identities and blurred responsibilities. Sometimes returning structured pieces is clearer, especially when consumers should understand which part owns form state and which part represents connectivity.

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. The abstraction has a name, but it has not created a meaningful boundary if there is no second use and no difficult lifecycle to hide.

Extract when:

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

The decision is about reducing meaningful complexity, not increasing the number of files or functions. A small local state declaration is often the clearest design when its ownership is obvious.

Hook testing philosophy

Prefer testing a Hook through a realistic component when possible because Hooks exist inside React lifecycle. A component test exercises the way the Hook is actually consumed, including rendering, effects, cleanup, and user-visible behavior.

For a complex shared Hook, renderHook can be useful:

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

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

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

The test checks the contract rather than React's internal implementation. For either style, include the edge cases that matter to the Hook: cleanup, changing inputs, errors, repeated calls, and external values where applicable. 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. A shared browser-status Hook can be used by many features without knowing what a task is. Feature-specific coordination belongs closer to the feature:

Feature Hook:

text
features/tasks/useTaskRealtime

can import task-domain infrastructure. This boundary keeps reuse honest and prevents a supposedly generic library layer from becoming an indirect feature controller.

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. A missing provider is a wiring error, not a normal unauthenticated state. The explicit error points maintainers to the boundary that must be fixed instead of allowing the application to continue with misleading data.

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 or library problems. They should not dominate everyday component code. Reach for the narrow primitive that matches the boundary you need to integrate, and prefer ordinary rendering, props, state, and Effects when those are sufficient.

Failure clinic

Hook calls another Hook conditionally

Breaks Rules of Hooks. Inspect every branch and early return around the Hook call, then move the call to the top level and conditionally use its result instead.

Hook recreates subscription every render

Often unstable object/function input. Check the Effect dependency list and the identities of configuration values passed into the Hook. The fix may be a better Hook API that extracts primitive dependencies, not another useMemo at every call site.

getSnapshot returns new object each call

Can cause infinite/inconsistent external store updates. A snapshot must represent the same unchanged external value with a stable identity, so return a primitive or a cached object.

Generic useApi

If it grows caching, mutations, retries, invalidation, polling, pagination, it is becoming a poor reimplementation of TanStack Query. Stop and define whether the code is an educational example, a deliberately small domain-specific client, or an accidental server-state library.

Deep-dive 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. It observes document visibility and exposes that value without deciding what a particular business feature should do with it.

The case study also gives you a useful debugging boundary. If the displayed visibility is wrong, first inspect the browser event and document.visibilityState, then inspect the Hook's snapshot and finally the consuming component. That separation makes it possible to determine whether the problem is browser observation, React synchronization, or business behavior rather than treating the whole feature as one opaque effect.

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. First check whether the existing library configuration already owns the behavior; otherwise the application can end up with duplicate requests or competing lifecycle rules.


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 because a test can provide a controlled service implementation. It is unnecessary ceremony for small apps, though. Dependency injection is helpful when it clarifies ownership or makes a meaningful boundary replaceable; it is not automatically better than a direct import.

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. A consumer can still use the individual values, and adding memoization everywhere creates its own cost and complexity.

Do not automatically:

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

unless the API/consumer actually requires stable identity or compiler/build strategy indicates it. First identify a real identity-sensitive consumer. Optimization should follow a contract or a measured problem, not a general fear of object creation.

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. A short generic name may be pleasant to type but leaves an important operational fact hidden from code review and from future consumers.

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. Each operation has different dependencies, cleanup, failure modes, and likely reuse boundaries. Split around responsibility, then compose at the feature component/provider level. The result may contain more than one Hook, but each Hook's behavior will be easier to name, test, and reason about.

When reviewing a proposed extraction, ask where the source of truth lives, what starts the lifecycle, and what stops it. Then ask whether the public API describes that responsibility or merely hides a group of implementation details. Those questions usually reveal whether the new Hook is a useful behavior module or just a renamed block of component code.

A practical review should also trace the inputs and outputs:

  • Which input changes should restart synchronization?
  • Which callback should always use current values?
  • Which returned values are state, commands, or status?
  • What happens when the provider or external source is unavailable?
  • What cleanup must happen when the consumer unmounts?

If those answers are difficult to give, the Hook contract is probably carrying too many concerns. Simplifying the boundary before adding optimization or more configuration will usually produce a more maintainable result.

That is the standard to carry into code review: extract behavior when it clarifies ownership, not merely because extraction is possible.

Reader page: /react/lesson/103/custom-hooks-and-advanced-built-in-hooks