Skip to content

Co-locate state and UI with React Hooks

Let's talk about hooks that are not just used by component but use components themselves. This lets us co-locate state and UI.

Published
Aug 05, 2023
Updated
Aug 05, 2023
Reading time
3 min read

React Hooks are used by components - but hooks can also use components. Sure, hooks cannot render them (or at least they shouldn't) but they can produce React nodes and let the consumer render them.

Here is an example:

function useDeleteItem(id) {
  const [isOpen, toggle] = useToggle();
  const { mutate } = useDeleteItemMutation(id);

  const trigger = <Button onClick={toggle} text="Delete" />;

  const confirmationDialog = (
    <ConfirmationDialog
      title="Delete item"
      text="Are you sure you want to..."
      onConfirm={mutate}
      onClose={toggle}
    />
  );

  return {
    isOpen,
    toggle,
    trigger,
    dialog: isOpen ? confirmationDialog : null,
  };
}

The hook provides a button to delete an item (whatever an item is), handles the open state, renders the confirmation dialog and calls the mutate function to delete the item. The code is simplified just to demonstrate the idea.

Using the delete action is now easier as the responsibility of managing the state and rendering the UI is moved into the hook and components don't have to know how the UI is rendered.

function ItemCard(props) {
  const deleteAction = useDeleteItem(props.id);

  return (
    <Card>
      <Title>{props.title}</CardTitle>
      <Content>...</Content>
      {deleteAction.trigger}
      {deleteAction.dialog}
    </Card>
  );
}

Pros

First, hooks combining state, logic and UI are easier to use. Consumers don't have to manage a separate state (like if the modal is open) and don't need to know how to render components.

Second, the hook can co-locate related code into a single function instead of splitting it into multiple pieces in the consuming component. All code related to a single feature is located in one place instead of being split into several pieces inside a large component that handles many unrelated things.

The example above could also be implemented as a component that manages its state and renders the button and dialog. Consumers could render the component without caring about the state.

function ItemCard(props) {
  return (
    <Card>
      <Title>{props.title}</CardTitle>
      <Content>...</Content>
      {/* Component does handles everything, no hook needed */}
      <DeleteItemAction id={id} />
    </Card>
  );
}

However, consumers have no control over the state anymore. If the component would like to know if the confirmation modal is open, we would have to turn it into a controlled component or at least emit events whenever the state changes. If the consumer would like to trigger the delete action from outside, the DeleteItemAction component would have to support ref and use useImperativeHandle. The hook on the other side can provide all those features much easier.

Cons

The UI is provided by the hook, giving the consumers less control over it. This can be a disadvantage if consumers need to customize the UI.

The hook lifts the state one level up. The state now lives inside the ItemCard component whereas with component-based APIs it would live in DeleteItemAction, the child component. State changes now re-render the ItemCard component which is not necessary if the ItemCard component doesn't use that state. So the hook-based approach is only useful if the consumers actually use the provided state or functions. Otherwise, it just impacts the performance negatively.

Props-only approach

An alternative are hooks that manage the state and only provide props, not React nodes. This approach is used by hook-based headless component libraries like React Aria and Zag. This offers more flexibility but also shifts more responsibility to the consumer.

Final words

It's not an approach that should be used for every hook. But it's a pattern that sometimes can be useful to make a component easier to use while keeping it flexible.