Custom Hooks

Published:

Two components can need the same behavior without needing the same markup. A status badge and a save button may both need to follow the browser's network status:

function StatusBadge() {
  // follow network status
  return <p>Online</p>;
}

function SaveButton() {
  // follow network status
  return <button>Save</button>;
}

A component can package reusable JSX, but these components do not render the same thing. What they share is the state and synchronization needed to keep isOnline current.

That leaves the next hole:

once Hooks exist, how can components reuse stateful logic without changing the rendered tree?

Sharing an ordinary value #

Start with the smallest extraction. Keep one value outside both components and update it when the external system changes:

let online = true;
const networkListeners = new Set();

const network = {
  getSnapshot() {
    return online;
  },
  subscribe(listener) {
    networkListeners.add(listener);
    return () => {
      networkListeners.delete(listener);
    };
  },
  setOnline(nextOnline) {
    online = nextOnline;
    networkListeners.forEach((listener) => listener());
  },
};

let isOnline = network.getSnapshot();
network.subscribe(() => {
  isOnline = network.getSnapshot();
});

function StatusBadge() {
  return <p>Status: {isOnline ? 'online' : 'offline'}</p>;
}

function SaveButton() {
  return <button disabled={!isOnline}>Save</button>;
}

function NetworkControls() {
  function changeNetwork(nextOnline) {
    network.setOnline(nextOnline);

    const output = mountNode.querySelector('[data-network-value]');
    output.textContent = String(isOnline);
  }

  return (
    <fieldset>
      <legend>Experiment controls</legend>
      <button onClick={() => changeNetwork(false)}>Go offline</button>
      <button onClick={() => changeNetwork(true)}>Go online</button>
      <button onClick={() => root.render(<App />)}>Render App again</button>
      <p>
        Ordinary variable: <output data-network-value>{String(isOnline)}</output>
      </p>
    </fieldset>
  );
}

function App() {
  return (
    <section>
      <StatusBadge />
      <SaveButton />
      <NetworkControls />
    </section>
  );
}

root.render(<App />);

Waiting to run

Not run yet.

Click Go offline. The diagnostic output changes to false because changeNetwork writes that text directly after the subscription updates isOnline. The React-rendered status still says online, and the Save button remains enabled.

The direct text update exists only to make the ordinary variable observable in this experiment. It does not render a component. Click Render App again and both components finally read the current value: the status becomes offline, and the Save button becomes disabled.

Go online and Render App again repeat the experiment in the other direction. Changing an ordinary variable gives React no reason to render either component again; asking the renderer to run App does.

The failed extraction exposes the first constraint:

reusable reactive behavior must preserve the React attachment that schedules rendering

The value alone is not the reusable behavior. The behavior includes reading a snapshot, subscribing to changes, scheduling another render, and cleaning up the subscription.

Repeating an Effect-based subscription #

Use the Hooks we already have inside each component:

const { useEffect, useState } = React;

let online = true;
const networkListeners = new Set();

const network = {
  getSnapshot() {
    return online;
  },
  subscribe(listener) {
    networkListeners.add(listener);
    return () => {
      networkListeners.delete(listener);
    };
  },
  setOnline(nextOnline) {
    online = nextOnline;
    networkListeners.forEach((listener) => listener());
  },
};

function StatusBadge() {
  const [isOnline, setIsOnline] = useState(() => network.getSnapshot());

  useEffect(() => {
    return network.subscribe(() => {
      setIsOnline(network.getSnapshot());
    });
  }, []);

  return <p>Status: {isOnline ? 'online' : 'offline'}</p>;
}

function SaveButton() {
  const [isOnline, setIsOnline] = useState(() => network.getSnapshot());

  useEffect(() => {
    return network.subscribe(() => {
      setIsOnline(network.getSnapshot());
    });
  }, []);

  return <button disabled={!isOnline}>Save</button>;
}

function NetworkControls() {
  return (
    <fieldset>
      <legend>Experiment controls</legend>
      <button onClick={() => network.setOnline(false)}>Go offline</button>
      <button onClick={() => network.setOnline(true)}>Go online</button>
    </fieldset>
  );
}

function App() {
  return (
    <section>
      <StatusBadge />
      <SaveButton />
      <NetworkControls />
    </section>
  );
}

root.render(<App />);

Waiting to run

Not run yet.

Click Go offline. This time both components update without a Render App again button: the status becomes offline, and the Save button becomes disabled. Go online reverses both changes.

Each component has a state slot, each subscribes after commit, and each removes its subscription on unmount. A network notification now reaches a state setter, which schedules the missing render.

The result updates correctly in this experiment, but the same Effect-based protocol appears twice:

read snapshot -> keep state -> subscribe -> update state -> unsubscribe

Copying that sequence makes every correction a multi-site change. One component might forget cleanup, read a different snapshot, or stop matching the others later.

There is also a smaller timing hole. The component reads the snapshot during render but does not subscribe until its Effect runs after commit. If the external value changes in between, the component can miss that notification and keep the earlier snapshot until another change occurs.

React provides useSyncExternalStore for exactly this boundary. It accepts a subscription function and a snapshot reader, keeps the rendered snapshot consistent with the external source, and schedules another render when a notification produces a different snapshot.[5]

const isOnline = useSyncExternalStore(
  network.subscribe,
  network.getSnapshot,
);

This replaces the hand-written state and Effect pair. The demos run only in the browser. A component rendered on the server must also supply the optional third getServerSnapshot argument.[5]

An ordinary helper can share calculations, but this subscription calls a Hook. The Rules of Hooks permit Hook calls in one more kind of function: another Hook.

Extracting a custom Hook #

Give the corrected subscription a domain-specific name by moving its built-in Hook call into a function:

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

This is a custom Hook. It is ordinary JavaScript in the sense that React provides no registration API for it. Its name declares the Hook contract, and its call to a built-in Hook gives the caller access to React-managed behavior.[1]

The name begins with use followed by a capital letter. That convention is part of the contract: it tells readers, callers, and lint tooling that the function can call Hooks and must itself be called according to the Rules of Hooks.[2]

Use the extracted value in components with unrelated markup:

const { useSyncExternalStore } = React;

const network = (() => {
  let online = true;
  const listeners = new Set();

  return {
    getSnapshot() {
      return online;
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    setOnline(nextOnline) {
      online = nextOnline;
      listeners.forEach((listener) => listener());
    },
  };
})();

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

function StatusBadge() {
  const isOnline = useOnlineStatus();
  return <p>Status: {isOnline ? 'online' : 'offline'}</p>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  return <button disabled={!isOnline}>Save</button>;
}

function NetworkControls() {
  return (
    <fieldset>
      <legend>Experiment controls</legend>
      <button onClick={() => network.setOnline(false)}>Go offline</button>
      <button onClick={() => network.setOnline(true)}>Go online</button>
    </fieldset>
  );
}

function App() {
  return (
    <section>
      <StatusBadge />
      <SaveButton />
      <NetworkControls />
    </section>
  );
}

root.render(<App />);

Waiting to run

Not run yet.

The observable behavior is unchanged. The badge still renders a paragraph, the save control still renders a button, and the same network controls update both components.

The custom Hook does not render either element. It returns a value that each component uses in its own render calculation.

That fills the structural part of the hole:

a custom Hook extracts React behavior without inserting another component into the React tree

Following the call through #

The earlier Hooks article used call position to explain how React finds state. A custom Hook does not create a second slot system. React can conceptually follow the calls until it reaches built-in Hooks:

StatusBadge render
└── useOnlineStatus()
    └── useSyncExternalStore(...)

SaveButton render
└── useOnlineStatus()
    └── useSyncExternalStore(...)

The useSyncExternalStore call made for StatusBadge attaches to the rendered StatusBadge. The call made for SaveButton attaches to the rendered SaveButton. The custom Hook organizes those calls; it does not own a rendered identity of its own.

This also explains why the caller must invoke useOnlineStatus at the top level:

function StatusBadge({ visible }) {
  if (visible) {
    const isOnline = useOnlineStatus(); // wrong: conditional Hook call
  }
}

Skipping one custom Hook call also skips every built-in Hook inside it. Later call positions would shift just as they would for a conditional useState call. Custom Hooks must therefore be called at the top level of a component or another custom Hook, and their own Hook calls must remain at the top level too.[2]

Condition the returned value or rendered output instead:

function StatusBadge({ visible }) {
  const isOnline = useOnlineStatus();

  if (!visible) {
    return null;
  }

  return <p>Status: {isOnline ? 'online' : 'offline'}</p>;
}

Sharing logic, not state #

The two calls to useOnlineStatus observe one external network, so their results move together. That can make it look as though a custom Hook creates shared state.

Use a Hook with no external source to test that prediction:

const { useState } = React;

function useCounter() {
  const [count, setCount] = useState(0);

  function increment() {
    setCount((currentCount) => currentCount + 1);
  }

  return { count, increment };
}

function Counter({ label }) {
  const { count, increment } = useCounter();

  return (
    <button onClick={increment}>
      {label}: {count}
    </button>
  );
}

function App() {
  return (
    <section>
      <Counter label="First" />
      <Counter label="Second" />
    </section>
  );
}

root.render(<App />);

setTimeout(() => {
  const buttons = mountNode.querySelectorAll('button');
  buttons[0].click();

  setTimeout(() => {
    console.log(buttons[0].textContent);
    console.log(buttons[1].textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

After the demo clicks the first button, only the first counter changes. Clicking the second button changes the second counter independently. Each Counter call reaches a separate state slot associated with that rendered component.

This gives the central distinction:[1]

custom Hooks share stateful logic, not the state created by that logic

If two components truly need one shared value, the owner must live somewhere they can both reach: for example in their nearest common parent, in context, or in an external store. A custom Hook can provide the interface to that owner, but calling it does not merge component state.

Giving a Hook inputs and outputs #

A custom Hook is a function, so parameters tell it what behavior this caller needs and its return value exposes the smallest useful interface.

For example, a connection Hook can accept a room identifier and hide the synchronization protocol:

function useRoomConnection(roomId) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

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

function ChatRoom({ roomId }) {
  useRoomConnection(roomId);
  return <h2>Room: {roomId}</h2>;
}

roomId remains a reactive value. Moving the Effect into a custom Hook does not make its dependencies less important; the Effect still reads roomId, so [roomId] keeps the connection synchronized with the current render.[4]

This Hook returns nothing because its job is synchronization. useOnlineStatus returns a value because rendering needs the current snapshot. useCounter returns an object because callers need both state and an operation.

The return shape is an abstraction boundary. Expose what callers need rather than leaking internal setters and subscription details by default:

return { count, increment }; // intention

instead of:

return [count, setCount]; // storage mechanism

The second form can be appropriate when callers genuinely need unrestricted updates. The point is to choose the interface deliberately.

Naming the behavior #

The name should describe the capability from the caller's point of view:

useOnlineStatus()
useRoomConnection(roomId)
useCounter()

Do not add use to every shared function. A function that only calculates a value does not need Hook privileges:

function formatStatus(isOnline) {
  return isOnline ? 'Online' : 'Offline';
}

Keeping it an ordinary function means it can be called anywhere. A use prefix would falsely restrict its callers and imply React behavior it does not contain.

The opposite mistake hides Hook calls inside an ordinary-looking name:

function getOnlineStatus() {
  const [isOnline] = useState(true); // hidden Hook contract
  return isOnline;
}

Even if a particular execution happens to preserve call order, the name tells callers that getOnlineStatus() is an ordinary helper. They may call it in an event handler or condition where Hooks are forbidden. Naming it useOnlineStatus makes the constraint visible and lets Hook-aware lint rules check its callers.[2]

Choosing the right extraction #

Not every repeated line needs a custom Hook. The kind of reuse determines the abstraction:

Repeated concern Extract
a calculation from arguments ordinary function
rendered structure and behavior component
state, context, refs, or synchronization used by components custom Hook

A custom Hook is justified when the shared idea depends on React features. It can give a domain name to a protocol such as "follow network status" or "maintain this room connection."

Avoid extracting solely to make a component shorter. A vague Hook such as useSetup can hide unrelated Effects, state, and refs behind one call without creating a coherent boundary. Prefer Hooks whose inputs, outputs, and synchronization purpose can be stated precisely.

Custom Hooks still run during rendering. Their calculations must remain pure, and side effects still belong in event handlers or Effects.[3] Extraction changes where the code is written, not when React executes it.

Filling the hole #

The failed shared variable taught us that reusable behavior cannot discard React's rendering connection. Duplicating the Effect-based subscription preserved that connection but scattered one protocol across its consumers. Closing its timing gap led to the purpose-built external-store Hook before extraction.

A custom Hook keeps both properties:

  1. its built-in Hook calls attach to each component that calls it
  2. one function owns the repeated stateful protocol
  3. parameters carry reactive inputs into that protocol
  4. returned values and operations form its public interface
  5. each call retains independent Hook state unless the Hook deliberately connects to a shared owner
  6. no component or host node is added to the rendered tree

The abstraction composes behavior through JavaScript calls while React continues to own the state, subscription, and Effect lifetimes underneath.

Final definition #

A custom Hook is a function whose name starts with use and that composes built-in or other custom Hooks into reusable React behavior.

Each call attaches that behavior to the calling component. It reuses the protocol, not the caller's state, and it changes no rendered tree shape unless the caller uses its result to return different elements.

Summary #

Custom Hooks fill the stateful-logic reuse hole:

  • an ordinary shared variable can change without scheduling a React render
  • repeating an Effect-based subscription can update each component but duplicates behavior and leaves a timing gap
  • useSyncExternalStore closes that gap for a mutable external source
  • a custom Hook extracts that protocol into one function
  • its name begins with use followed by a capital letter
  • custom Hooks and their callers obey the same Rules of Hooks
  • built-in Hooks inside the custom Hook remain attached to the calling component
  • separate calls share logic, but any state they create remains independent
  • shared state still needs a shared owner
  • parameters carry reactive inputs and return values expose a deliberate interface
  • custom Hooks do not add components or DOM nodes
  • pure calculations remain ordinary functions, while reusable JSX remains a component concern

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. The small `network` objects stand in for a browser or external subscription API so that each demo can change connectivity deterministically.

References

  1. React: Reusing Logic with Custom Hooks (opens in a new tab) · Back
  2. React: Rules of Hooks (opens in a new tab) · Back
  3. React: Components and Hooks must be pure (opens in a new tab) · Back
  4. React `useEffect` API reference (opens in a new tab) · Back
  5. React `useSyncExternalStore` API reference (opens in a new tab) · Back