David Dunn

Senior Software Developer


Back to Blog

5 Common Custom Hook Patterns

9 min read
react typescript

A practical mental model for shaping custom hooks: resource, behaviour, integration, controller, and adapter.

Custom hooks have a wide variety of applications and in this post we’re going to look at 5 common patterns for using custom hooks.

This post is more of a surface level map where the goal is for us to build a mental model of what is available and when we may reach for it. In future posts we will explore each pattern more deeply.

The 5 Patterns

  1. Controller hooks
  2. Resource, or data, hooks
  3. Behaviour hooks
  4. Integration hooks
  5. Adapter hooks

Now while we’ve broken them out into 5 separate hooks, they can overlap. Internally the hooks may behave the same as eachother, for example a resource hook might internally behave like a small state machine which is valid but it is the consumer experience that should be consistent.

1. Controller Hooks

When To Use

If we want a hook that behaves like a feature controller, where it owns the rules and exposes a small public API, then we look at controller hooks.

In this pattern, hooks become tiny state machines to handle the API for some small feature.

Example: useAsyncAction()

Can be used for forms, buttons, submissions, retries etc etc.

import * as React from "react";

type ActionState =
  | { status: "idle"; error: null }
  | { status: "running"; error: null }
  | { status: "success"; error: null }
  | { status: "error"; error: Error };

export function useAsyncAction<Args extends unknown[], Result>(
  fn: (...args: Args) => Promise<Result>
) {
  const [state, setState] = React.useState<ActionState>({
    status: "idle",
    error: null,
  });

  const run = React.useCallback(
    async (...args: Args) => {
      setState({ status: "running", error: null });
      try {
        const result = await fn(...args);
        setState({ status: "success", error: null });
        return { ok: true as const, result };
      } catch (e) {
        const err = e instanceof Error ? e : new Error("Unknown error");
        setState({ status: "error", error: err });
        return { ok: false as const, error: err };
      }
    },
    [fn]
  );

  const reset = React.useCallback(() => {
    setState({ status: "idle", error: null });
  }, []);

  return {
    status: state.status,
    error: state.error,
    isRunning: state.status === "running",
    run,
    reset,
  };
}

Why Controller Hooks Are Useful

Controller hooks stop components from turning into:

Instead the hook handles this for us and our component is simplified into just rendering the UI.

2. Resource (Data) Hooks

When To Use

If we have components that need access to some data and the lifecycle around it, such as loading/error/success, then we can use resource hooks.

Example: useUser(userId)

import * as React from "react";

type AsyncState<T> =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: null; error: Error };

type User = { id: string; name: string };

async function fetchUser(userId: string, signal?: AbortSignal): Promise<User> {
  const res = await fetch(`/api/users/${userId}`, { signal });
  if (!res.ok) throw new Error(`Failed to fetch user (${res.status})`);
  return res.json();
}

export function useUser(userId: string | null) {
  const [state, setState] = React.useState<AsyncState<User>>({
    status: "idle",
    data: null,
    error: null,
  });

  const refetch = React.useCallback(() => {
    if (!userId) return;

    const controller = new AbortController();

    setState({ status: "loading", data: null, error: null });

    fetchUser(userId, controller.signal)
      .then((user) => setState({ status: "success", data: user, error: null }))
      .catch((err) => {
        // Ignore abort errors so you don't flash errors on fast re-renders
        if (err instanceof DOMException && err.name === "AbortError") return;
        setState({ status: "error", data: null, error: err as Error });
      });

    return () => controller.abort();
  }, [userId]);

  // Automatically fetch when userId changes
  React.useEffect(() => {
    const cleanup = refetch();
    return cleanup;
  }, [refetch]);

  const isLoading = state.status === "loading";
  const isError = state.status === "error";

  return {
    ...state,
    isLoading,
    isError,
    refetch,
  };
}

Why Resource Hooks Are Useful

Resource hooks typically have a standard return shape:

Making them fairly predictable and consistent across use cases.

However it is common that in cases where we fetch data we may need caching, deduping, retries, background refetch, pagination… etc etc.

At that point we should reach for a dedicated library, like TanStack Query, and an adapter hook

In a later post we will go over these patterns in greater detail including how to combine them with popular libraries.

3. Behaviour Hooks

When To Use

In cases where we need to reuse UI logic across many components, like state and handlers, we’d look at using behaviour hooks.

These hooks tend to provide behaviour, not data, to components that use them.

Example: useDisclosure()

import * as React from "react";

type DisclosureOptions = {
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
};

export function useDisclosure(options: DisclosureOptions = {}) {
  const { defaultOpen = false, onOpenChange } = options;

  const [open, setOpen] = React.useState(defaultOpen);

  const set = React.useCallback(
    (next: boolean) => {
      setOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange]
  );

  const openIt = React.useCallback(() => set(true), [set]);
  const closeIt = React.useCallback(() => set(false), [set]);
  const toggle = React.useCallback(() => set(!open), [set, open]);

  return {
    open,
    setOpen: set,
    openIt,
    closeIt,
    toggle,
  };
}

Why Behaviour Hooks Are Useful

There are times when components want to share some behavioral logic, like pagination, debouncing or the useDisclosure above for handling modals.

Otherwise we’d have to duplicate this logic across many components and keep it in sync.

We need to avoid returning too much state as we run the risk of turning our ‘modal’ hook into an entire feature, at which point it’d be closer to a controller hook.

We also want to avoid mixing in extra logic or side effects, such as analytics, API calls or navigation. These hooks should be as simple as possible to cover the behaviour required.

4. Integration Hooks

When To Use

Sometimes we need a component to integrate with something outside of React such as; DOM events, timers, browser APIs etc etc. We can encapsulate this logic within an integration hook.

Example: useEventListener()

import * as React from "react";

type AnyTarget = Window | Document | HTMLElement;

export function useEventListener<K extends keyof WindowEventMap>(
  target: Window | null,
  type: K,
  handler: (event: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
): void;
export function useEventListener<K extends keyof DocumentEventMap>(
  target: Document | null,
  type: K,
  handler: (event: DocumentEventMap[K]) => void,
  options?: AddEventListenerOptions
): void;
export function useEventListener(
  target: AnyTarget | null,
  type: string,
  handler: (event: Event) => void,
  options?: AddEventListenerOptions
) {
  // Keep latest handler without re-subscribing every render
  const handlerRef = React.useRef(handler);
  React.useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  React.useEffect(() => {
    if (!target) return;

    const listener = (event: Event) => handlerRef.current(event);
    target.addEventListener(type, listener, options);

    return () => target.removeEventListener(type, listener, options);
  }, [target, type, options]);
}

Why Integration Hooks Are Useful

With integration hooks we’re able to encapsulate the logic around intgrating with external APIs. By external APIs we mean APIs outside of React.

Like previous hook examples we keep the implementation as generic as possible, even making use of overloading in TypeScript in the above example.

We must ensure we perform appropiate clean ups to avoid memory leaks, in the above example we remove the event listener for example.

We also want to avoid the ‘closure’ issue where we capture stale state which is where useRef can be useful.

5. Adapter Hooks

When To Use

As powerful as React is we often, if not always, eventually reach out and use an external library, such as TanStack query or an authentication SDK. In those cases we often want one consistent app-shaped interface.

Example: useApi() + useApiQuery()

Here’s a small adapter around fetch that standardises errors and JSON.

import * as React from "react";

type ApiError = {
  message: string;
  status?: number;
};

async function apiFetch<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
  const res = await fetch(input, init);

  if (!res.ok) {
    let message = `Request failed (${res.status})`;
    try {
      const body = (await res.json()) as { message?: string };
      if (body?.message) message = body.message;
    } catch {
      // ignore JSON parsing errors
    }
    throw { message, status: res.status } satisfies ApiError;
  }

  return res.json();
}

type QueryState<T> =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: null; error: ApiError };

export function useApiQuery<T>(key: string, url: string | null) {
  const [state, setState] = React.useState<QueryState<T>>({
    status: "idle",
    data: null,
    error: null,
  });

  React.useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setState({ status: "loading", data: null, error: null });

    apiFetch<T>(url, { signal: controller.signal })
      .then((data) => setState({ status: "success", data, error: null }))
      .catch((e: ApiError | unknown) => {
        if (e instanceof DOMException && e.name === "AbortError") return;
        const err =
          typeof e === "object" && e && "message" in e
            ? (e as ApiError)
            : { message: "Unknown error" };
        setState({ status: "error", data: null, error: err });
      });

    return () => controller.abort();
  }, [key, url]);

  return {
    ...state,
    isLoading: state.status === "loading",
  };
}

Why Adapter Hooks Are Useful

We may use an adapter hook with TanStack query to:

By doing this, our hook provides a contract for the rest of the app while also de-coupling our app from the library we’re using.

So if in future we wanted to swap to a different router library we’d only need to update our hook logic, not the entire application.

Conclusion

As mentioned at the start of this post we took a very surface level look at some of the common patterns with custom hooks.

In future posts we will dive much deeper into them, and look at real world scenarios where we’d use them.