Home / Articles

Keep Hooks Headless: Separating Alert Behavior from Alert Rendering

Learn why a React hook that returns JSX hides part of your component tree, couples behavior to presentation, and how to refactor it to expose state instead.

Publisher: REACTAPP.TOP5 min read1092 words

This article is published in English.

Sources: Why I’m Suspicious of Hooks That Return JSX

Custom hooks are excellent at hiding implementation details, and occasionally they hide too much. A hook that returns ready-made JSX looks convenient at the call site, but it moves part of your UI tree behind an API that reads like plain data, and it welds behavior to one specific presentation. Using a typical alerts hook from a dashboard editor, this article shows how to recognize the smell, how to refactor toward a headless hook, and what you gain from the extra few lines.

An alerts hook that renders its own UI

Alerts are a natural fit for a hook: they need state, possibly timers, close handlers and some queueing. Here is how such a hook is consumed:

const {
  showSuccess,
  showError,
  alerts,
} = useAlerts();

return (
  <>
    {alerts}

    <Dashboard />
  </>
);

There is already a hint that something is off. alerts is dropped straight into the JSX, which strongly suggests the hook returns rendered elements. The hook is responsible for behavior, since it hands out showSuccess and showError, and apparently also for how alerts look.

The implementation confirms it:

const useAlerts = () => {
  const [alerts, setAlerts] = useState<Alert[]>([]);

  const showSuccess = (message: string) => {
    // update state
  };

  const showError = (message: string) => {
    // update state
  };

  ...

  return {
    showSuccess,
    showError,
    alerts: (
      <AlertContainer>
        {alerts.map(({ id, type, message }) => (
          <Alert
            key={id}
            type={type}
            message={message}
          />
        ))}
      </AlertContainer>
    ),
  };
};

useAlerts owns the alert state, the functions that change it, and the markup: the AlertContainer wrapper and one Alert per entry. The boundary between logic and presentation has disappeared.

Why the returned JSX is misleading

It looks like data

From the consumer's side, nothing signals that alerts is a React subtree. It sits beside two functions, has a plural name that suggests an array of records, and could easily be mistaken for state:

const {
  showSuccess,
  showError,
  alerts,
} = useAlerts();

The only way to learn the truth is to open the hook.

It hides where the UI comes from

A component tree is valuable partly because it documents where each piece of UI originates. When reviewing the dashboard and seeing this:

{alerts}

<Dashboard />

you cannot tell what alerts renders, which components it mounts, which context or props those components depend on, or how they are styled, without jumping into the hook. Part of the tree has been relocated behind something that looks behavioral.

It couples two unrelated decisions

The hook can no longer expose alert behavior without also deciding the alert component. Any screen that wants the alert logic inherits the alert look, whether or not it fits. There is no technical reason for those two concerns to travel together.

Refactoring to a headless hook

The hook should own the state and the operations on it, and nothing else. Expose the raw alert list plus the actions, including a way to dismiss an alert:

const {
  alerts,
  showSuccess,
  showError,
  dismissAlert,
} = useAlerts();

Then render the alerts in the component, where rendering already belongs:

<AlertContainer>
  {alerts.map(({ id, type, message }) => (
    <Alert
      key={id}
      type={type}
      message={message}
      onDismiss={() => dismissAlert(id)}
    />
  ))}
</AlertContainer>

<Dashboard />

Now the component tree is explicit again. Anyone reading it sees an AlertContainer, one Alert per entry keyed by id, and a dismiss handler wired back to the hook. The hook's role is unambiguous: it supplies state and actions.

Is the extra code a problem?

The consuming component does get longer, and that is fine. It is tempting to polish an API until the caller writes almost nothing, but that polish can erase useful architectural information. Five lines of obvious composition are easier to maintain than a single {alerts} concealing a whole piece of UI.

If several screens render alerts identically, you can still avoid repetition without folding the UI back into the hook: create an AlertList component that takes alerts and onDismiss as props, or one that calls the hook itself. Either way the rendering lives in a component, which is visible in the tree and can be profiled, memoized and tested like any other component.

Reuse becomes possible

With JSX baked in, reusing the behavior forces reuse of the presentation, an assumption that will not always hold. With the headless version, a toast area, an inline banner and a mobile layout can all share the exact same alert lifecycle while rendering it differently. The alert component can also evolve on its own without touching the hook that manages timing and dismissal.

A related trap: hooks that return components

A close cousin of this smell is a hook that defines and returns a component function, such as const AlertView = () => ... inside the hook body. Because a new function is created on every render, React sees a new component type each time and unmounts and remounts that subtree, wiping its internal state and DOM. Returning elements, as the original hook does, avoids that particular bug, but the design problem described above remains.

Key takeaways

  • JSX coming out of a hook usually means two responsibilities have been merged into one abstraction.
  • A returned value that looks like data but is actually UI hides part of the component tree from readers.
  • Keep hooks headless: return state and actions, and render in components.
  • For shared presentation, extract a component rather than returning markup from the hook.
  • When reviewing such a hook, ask one question: what reason does this hook have to decide how the UI appears?