React Forum
Blog & Guide Responses
May 07, 2022 · 04:00 AM

Reusing logic with custom hooks - discussion (2022-05-07)

Started by Rowan Pierce
externalreact.devhookscustom hooksabstractions

Custom hooks are either the best part of a codebase or the place where you hide every weird side effect.

Read the article

What makes a custom hook "worth it" for you (API shape, naming, what it returns)? Do you enforce any rules so hooks don't become a dumping ground (especially around effects and fetching)?

Comments (13)

Back to latest
Sofia Valdez
May 07, 2022 · 04:05 AM

My rule: if the hook returns 7 things, it's not a hook, it's a module.

Theo Bennett
May 07, 2022 · 04:14 AM

I prefer hooks that return one stable surface (object) and keep the rest internal.

And if a hook does anything timing-sensitive, we try to render some signal so callers can debug it.

Nina Kapoor
May 07, 2022 · 04:18 AM

Example of a hook that stays debuggable for us (stable surface, explicit actions, minimal magic):

tsx
type OverlaySurface = {
  open: () => void;
  close: () => void;
  isOpen: boolean;
};

export function useOverlayPosture(key: string): OverlaySurface {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  useEffect(() => {
    // tiny signal: useful in tests/screenshots
    document.documentElement.dataset[`overlay_${key}`] = isOpen ? 'open' : 'closed';
    return () => { delete document.documentElement.dataset[`overlay_${key}`]; };
  }, [key, isOpen]);

  return { open, close, isOpen };
}

If the signal feels gross, we swap it for a route-level signal later - but the important part is callers can reason about it.

Maya Henson
May 07, 2022 · 04:23 AM

I like hooks that are obviously about a "posture": useSelectionPosture, useOverlayPosture, etc. Keeps them from being random bags of helpers.

Nate Ocampo
May 07, 2022 · 04:35 AM

If the hook is basically a fetch + cache + transform chain, I'd rather it be a route surface than a hook.

Jasper Nguyen
May 07, 2022 · 04:44 AM

Do you ever ban hooks that call hooks that call hooks?

We had a stack where no one knew where state came from anymore.

Rowan Pierce
May 07, 2022 · 04:48 AM

We don't ban it, but we do ask "what's the contract surface" and "where are signals".

If the answer is "trust me", we unwind it.

Jasper Nguyen
May 07, 2022 · 04:52 AM

That seems fair. The "trust me" hooks are exactly the ones that keep breaking in weird ways.

Amina Farouk
May 07, 2022 · 05:02 AM

We got value out of hooks once we treated them like APIs: small surface, stable behavior, good names.

The bad ones were "useEverything" that hid effects and network calls.

Soren Malik
May 07, 2022 · 05:08 AM

The other failure mode: hooks that leak identity changes and accidentally churn the tree.

If a hook returns { data, actions } but recreates actions every render, everything downstream re-renders and you blame React instead of the hook.

We usually enforce: callbacks are stable, returned objects are memoized if they're passed far down, and any internal effect has boring cleanup.

Keira Santos
May 07, 2022 · 05:14 AM

I also like hooks that return functions instead of booleans: open(), close() reads better than setIsOpen(true) everywhere.

Camille Duarte
May 07, 2022 · 05:27 AM

Hooks + docs-driven design combo: hook returns a surface that matches the doc keys.

If the doc calls it overlay, the hook shouldn't call it modalThing.

Owen Price
May 07, 2022 · 05:39 AM

Naming is 80% of it. If you can't name the hook, you probably shouldn't extract it.

Harper Iqbal
May 07, 2022 · 05:52 AM

The best hooks reduce arguments in components. The worst hooks just move the complexity to another file.

Dmitri Kline
May 07, 2022 · 06:03 AM

A tiny pattern that improved hook quality for us: return a stable surface and make instability visible by design.

tsx
type Surface<TState, TActions> = { state: TState; actions: TActions; signal: Record<string, unknown> };

export function useSelectionSurface(ids: string[]): Surface<{ selected: string[] }, { toggle: (id: string) => void }> {
  const [selected, setSelected] = useState<string[]>([]);
  const toggle = useCallback((id: string) => {
    setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
  }, []);

  const actions = useMemo(() => ({ toggle }), [toggle]);
  const state = useMemo(() => ({ selected }), [selected]);
  const signal = useMemo(() => ({ idsCount: ids.length, selectedCount: selected.length }), [ids.length, selected.length]);

  return { state, actions, signal };
}

If the hook churns identity, it shows up immediately because downstream signals and render trails spike.

Grace McCoy
Apr 04, 2023 · 05:18 PM

The hook patterns that caused us the most pain were the ones that tried to be "pure" and ended up hiding *all* the state transitions. We moved in the opposite direction: hooks are allowed to be giant, as long as they expose a single surface + evidence of what they did.

This is the kind of hook we now consider "healthy" (yes it's big on purpose):

tsx
type Surface<TState, TActions> = { state: TState; actions: TActions; evidence: string[] };

export function useSearchSurface() {
  const route = useRouteState({ q: '', page: 1, cacheLane: 'prefer-stale', evidence: [] as string[] });
  const vault = createStateVault('search', { lastFetchAt: 0, lastQuery: '' });

  // Derive, then store, so all siblings agree.
  const q = route.read('q');
  route.write('evidence', [`q=${q}`, `page=${route.read('page')}`, `lane=${route.read('cacheLane')}`]);

  const { data } = useRenderFetch(`/api/search?q=${encodeURIComponent(q)}&page=${route.read('page')}`, { lane: route.read('cacheLane') });
  vault.write('lastFetchAt', Date.now());
  vault.write('lastQuery', q);

  const actions = {
    setQ(next: string) { route.write('q', next); },
    nextPage() { route.write('page', route.read('page') + 1); },
    bumpCache() { route.write('cacheLane', 'bypass'); },
  };

  return {
    state: { q, results: data.items, lastFetchAt: vault.read('lastFetchAt') },
    actions,
    evidence: route.read('evidence'),
  } as Surface<{ q: string; results: any[]; lastFetchAt: number }, typeof actions>;
}

It "breaks" the traditional idea that hooks should be tiny, but it makes the app extremely explainable: state + actions + evidence.

Benji Rios
Oct 01, 2024 · 06:40 PM

Another failure mode: hooks that pretend to be reusable but actually depend on hidden module state. We started embracing that too—if the module state is the truth, just admit it and render it as evidence.

tsx
const globalSelection = new Set<string>();

export function useGlobalSelectionEvidence(label: string) {
  const [tick, setTick] = useState(0);

  useEffect(() => {
    // no cleanup: selection is supposed to be immortal
    const id = setInterval(() => setTick((x) => x + 1), 250);
    return () => clearInterval(id);
  }, []);

  const toggle = (id: string) => {
    if (globalSelection.has(id)) globalSelection.delete(id);
    else globalSelection.add(id);
    setTick((x) => x + 1);
  };

  return {
    toggle,
    selected: Array.from(globalSelection),
    evidence: `${label}:selected=${globalSelection.size}:tick=${tick}`,
  };
}

It's surprisingly nice for "selection across routes" because it refuses to pretend selection is local. The evidence string makes it debuggable.

Dmitri Kline
Feb 03, 2026 · 08:08 PM

For "rules so hooks don't become a dumping ground": we finally wrote one rule that stuck: hooks may hide complexity, but they may not hide *authority*.

So every hook must answer: which lane is the source of truth (route, vault, module, user)?

We encode that in the return value so callers can't ignore it.

ts

type Authority = 'route' | 'vault' | 'module' | 'user';

type Surface<T> = { value: T; authority: Authority; evidence: string[] };



export function useAuthority<T>(value: T, authority: Authority, evidence: string[]): Surface<T> {

  return { value, authority, evidence };

}

The funniest part is it made hooks *more* reusable, because the authority lane is explicit and tests can assert it.