David Dunn

Senior Software Developer


Back to Blog

Controller Hooks - Custom Hooks as Tiny State Machines

7 min read
react typescript

Hooks are one of the most powerful features of modern React and in this post we will look at how we can use hooks to build state machines.

When we’re building React components it can be quite easy for the state to grow out of control.

For example, imagine we have a component that fetches some data from an external API. In this case we need to read and use multiple values:

Based on these state values the UI must update. This often leads to overly large components with multiple conditional checks to try to cover all cases the UI could exist in leading to a kind of boolean soup.

We’re going to explore how we can improve this using custom hooks.

First lets look at a common issue.

The Problem Of Impossible States

If we take the following simple example:

type State = {
  isLoading: boolean;
  isSuccess: boolean;
  error: string | null;
};

const state: State = {
  isLoading: false,
  isSuccess: true,
  error: "Network error", // success + error at the same time
};

We can see that its actually possible to have a state where we have an error but isSuccess is also true. How should the UI respond to this?

If the state we track is larger, the UI flow would be larger which would make this problem much greater.

In reality we’d rarely handle server state like this ourselves, we’d rely on an external library like TanStack Query (React Query) but this problem isn’t only related to server state.

So lets see how we can solve this.

The Custom Hook Mental Model

A well designed hook in React is really just a module with a public API.

If we think in two groups, ‘ask` and ‘do’.

Ask: These are the values a component can read, such as state and derived state

Do: These are functions that cause transitions such as; submit(), reset(), retry() etc etc.

Our UI components should mostly ‘ask’ the hook, and also allow the hook to ‘do’.

Let us look at what this looks like:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "running" }
  | { status: "success"; data: T }
  | { status: "error"; error: unknown };

type AsyncAction<T> =
  | { type: "RUN" }
  | { type: "RESOLVE"; data: T }
  | { type: "REJECT"; error: unknown }
  | { type: "RESET" };

function asyncReducer<T>(
  state: AsyncState<T>,
  action: AsyncAction<T>
): AsyncState<T> {
  switch (action.type) {
    case "RUN":
      if (state.status === "running") return state;
      return { status: "running" };

    case "RESOLVE":
      return { status: "success", data: action.data };

    case "REJECT":
      return { status: "error", error: action.error };

    case "RESET":
      return { status: "idle" };

    default: {
      const _exhaustive: never = action;
      return state;
    }
  }
}

type UseAsyncActionReturn<T> = {
  // Ask
  state: AsyncState<T>;
  isIdle: boolean;
  isRunning: boolean;
  isSuccess: boolean;
  isError: boolean;
  canRun: boolean;
  data: T | null;
  error: unknown | null;

  // Do
  run: () => Promise<void>;
  reset: () => void;
};

export function useAsyncAction<T>(
  fn: () => Promise<T>
): UseAsyncActionReturn<T> {
  const [state, dispatch] = React.useReducer(asyncReducer<T>, {
    status: "idle",
  });

  // stable refs to avoid stale closures if needed
  const fnRef = React.useRef(fn);
  React.useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  const run = React.useCallback(async () => {
    dispatch({ type: "RUN" });

    try {
      const data = await fnRef.current();
      dispatch({ type: "RESOLVE", data });
    } catch (error) {
      dispatch({ type: "REJECT", error });
    }
  }, []);

  const reset = React.useCallback(() => {
    dispatch({ type: "RESET" });
  }, []);

  // derived state: put rules in one place
  const isIdle = state.status === "idle";
  const isRunning = state.status === "running";
  const isSuccess = state.status === "success";
  const isError = state.status === "error";

  const canRun = !isRunning;

  const data = state.status === "success" ? state.data : null;
  const error = state.status === "error" ? state.error : null;

  return {
    state,
    isIdle,
    isRunning,
    isSuccess,
    isError,
    canRun,
    data,
    error,
    run,
    reset,
  };
}

So in the example above we have a hook that wraps an async function and manages the state transitions of that function explicitly.

If we look in terms of ‘ask’ and ‘do’, then:

So when we have a component that wants to use this hook, the component can ask for any state information it needs and if required get it to do some action.

For example:

import { useAsyncAction } from "./useAsyncAction";

async function fakeSave(): Promise<{ id: string }> {
  await new Promise((r) => setTimeout(r, 800));
  return { id: crypto.randomUUID() };
}

export function SaveButton(): JSX.Element {
  const save = useAsyncAction(fakeSave);

  return (
    <div style={{ display: "grid", gap: 12 }}>
      <button onClick={save.run} disabled={!save.canRun}>
        {save.isRunning ? "Saving..." : "Save"}
      </button>

      {save.isSuccess && <p>Saved! id: {save.data?.id}</p>}
      {save.isError && <p>Something went wrong.</p>}

      {(save.isSuccess || save.isError) && (
        <button onClick={save.reset}>Reset</button>
      )}
    </div>
  );
}

This allows us to keep our UI components quite simple, or dumb as some call it, which has numerous benefits. They’re easier to reason about, easier to debug and easier to test.

When To Use & When To Reach For External Library

So we’d be right in thinking… Why not use an external library? A library like XState for example?

Well first, as developers it is important for us to have an understanding of what the libraries we use are trying to do but also some problems aren’t large enough for external libraries.

We can reach for the above pattern of discriminated unions with reducers when:

But sometimes it is better to reach for a formal state machine library, like XState, when:

Common Mistakes

Returning setState Methods

If we return setters from our hooks we allow every caller to invent their own transitions.

Most of us have probably seen many examples of custom hooks returning setState methods which is perfectly valid but not in state machines. In these cases we have explicitly defined the states and the transitions between them. If we allow the caller to invent their own then we lose the value of using a state machine.

So instead we return the verbs, the ‘do’ commands, instead.

Returning Raw State & Duplicated Booleans

We should pick a consistent ‘story’ for our hook, we have a state machine so we should be able to choose:

We should try to avoid returning too many flags.

We could argue UseAsyncActionReturn has too many flags and we could reduce this to:

type UseAsyncActionReturn<T> = {
  state: AsyncState<T>;
  canRun: boolean;
  run: () => Promise<void>;
  reset: () => void;
};

Then allow the caller to derive their own values:

const isRunning = action.state.status === "running";
const data = action.state.status === "success" ? action.state.data : null;

By doing this the API is smaller but the components are more verbose. So this is a decision we have to make when designing our hooks and components.

Ultimately returning several derived values is perfectly fine when they are:

So in this sense the original UseAsyncActionReturn could be acceptable depending on the context of the code base it is used in.

Derived State In Multiple Components

We want to avoid components having to implement their own derived state.

If three different components implement their own canSubmit we’d end up with three different rules to maintain of how this should work.

In this case we should add it to the hook and avoid this problem.

Conclusion

In this post we used hooks as state machines but hooks are not limited to that, we can use hooks to encapsulate all kinds of logic.

In future posts we will explore different use cases of hooks as they’re an incredibly power feature of React.

In a future post we will also explore how we can combine the ideas from this post with TanStack Query.