HeyBinyang

Custom Hooks: How to Design for Logic and Mental Model Reuse

·Tech·visibility1 read ·React

When many people first create custom hooks, their starting point is often similar:

  • The component has three or four useState

  • Multiple useEffect

  • Plus some event handling logic

  • The code keeps getting longer and looks exhausting

So they casually extract a useXxx.ts, moving the code over. The file gets shorter, but new problems soon appear:

  • The hook has many inputs and many return values.

  • Although the logic has been moved, it's still messy to read.

  • When child components call it, they don’t know which return values are “state”, which are “actions”, and which are just internal implementation.

This shows that the problem is not “whether to extract a hook”, but rather:

A custom hook is not just “moving code to another file”, but rather redesigning a piece of stateful logic into a stable interface.

If a component is a unit of UI composition, then a custom hook is a unit of stateful logic composition.

1. Don't rush to extract: when is it worth making a custom hook?

React's official definition of a custom hook is quite simple:

  • It is a function that starts with use;

  • It can call other hooks inside;

  • Its purpose is to reuse stateful logic across multiple components.

But in daily projects, “reuse” is only one reason. In practice, common extraction scenarios typically fall into three categories:

1. The same stateful logic appears repeatedly in multiple components

For example:

  • useWindowSize

  • useOnlineStatus

  • useDebouncedValue

  • useFetchUser

This is the most intuitive case — typical logic reuse.

2. The combination of hooks in a component has formed “a complete capability”

For example:

  • Search input + debounce + request + cancel old request

  • Modal open/close + ESC close + click outside to close

  • Form values + validation + submit + reset

Even if only one component uses it at the moment, it's worth extracting. Because you are not just “reusing lines of code” — you are reusing a complete chunk of interaction semantics.

3. You want to turn a component from a “business controller” back into a “UI description”

This is the kind of scenario I find most worthwhile.

Many components start out like this:

  • A few useState at the top

  • Three useEffect in the middle

  • Five handlers below

  • And finally the JSX

It reads like a tiny controller, not a view. At this point, extracting a hook isn't for showing off — it's to turn the component back into a “presentation layer”.

2. Custom hooks don't extract code — they extract “problem boundaries”

This is the most important sentence in the entire article.

When many people extract hooks, they tend to split by “technical concerns”:

  • One hook handles state

  • One hook handles effects

  • One hook handles callbacks

The result is a bunch of useA, useB, useC that nobody understands.

A better approach is to split by “problem boundaries” — that is, split according to the business issue you are solving.

For example, if you are building a search panel, don't split it into:

  • useKeywordState

  • useDebounce

  • useSearchEffect

Instead, first think about:

  • Is this piece of logic, as a whole, a “search capability”?

  • What are the most important inputs and outputs of this capability?

Then you get a more semantic interface:

const {
  query,
  setQuery,
  results,
  loading,
  error,
} = useSearchUsers();

That way, the caller doesn't need to know whether it uses useEffect, useRef, or useCallback internally. It only cares that this hook provides a “user search capability”.

So when designing a custom hook, prioritize answering these two questions:

  1. What complete problem does this hook solve?

  2. What does the calling component absolutely need to know?

3. A bad example: just moving component code out is not design

Let's look at a common piece of code that is “extracted but not really extracted”.

Original logic inside the component

function SearchPanel() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return ...
}

Many people would directly extract it into:

function useSearchPanelLogic() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    ...
  }, [query]);

  return { query, setQuery, users, loading };
}

This isn't wrong, but it's hardly “design”. Because it simply moved the entire component logic to another file and renamed it to a hook.

The problem is:

  • The name useSearchPanelLogic is too tied to the page — it's not a capability name.

  • If another place later needs to “search users”, this hook may not be reusable.

  • It binds “page” and “capability” together.

A better approach is to extract a hook with a clearer semantic meaning:

function useUserSearch(initialQuery = '') {
  const [query, setQuery] = useState(initialQuery);
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!query) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [query]);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
  };
}

At this point it's no longer “moving page logic around” — it's a truly bounded state capability module.

4. When designing custom hooks, keep inputs and outputs “few and stable”

The most common way custom hooks get out of control is too many return values.

For example:

const {
  query,
  setQuery,
  users,
  setUsers,
  loading,
  setLoading,
  error,
  setError,
  refetch,
  reset,
  inputRef,
} = useUserSearch();

This kind of API immediately feels wrong. The reason is not “too many values” per se, but that it exposes internal state management to the outside.

A well-designed hook's public API usually looks like two categories:

  • State

  • Actions

For example:

const {
  query,
  users,
  loading,
  error,
  setQuery,
  refetch,
  reset,
} = useUserSearch();

Here it's clear:

  • query/users/loading/error are states.

  • setQuery/refetch/reset are actions.

Items like setUsers, setLoading, setError are generally internal implementation details of the hook and should not be casually exposed to callers. Otherwise the hook loses its constraint, and the outside can tamper with its state arbitrarily.

So there is a very practical design principle: > A hook's return value should prioritize exposing “readable states” and “semantic actions”, and avoid exposing internal setters.

This is the same philosophy as component encapsulation.

5. Solidify the rules from previous articles into your hooks

The most valuable thing about custom hooks is not “reusing code”, but solidifying the best practices from previous articles into reusable default patterns for your team.

In other words, a good custom hook should naturally have these characteristics:

  • Doesn't misuse effects.

  • Doesn't synchronize derived state.

  • Handles closures and dependency arrays properly.

  • Only uses ref and callback when necessary.

For example, we've talked about “debounced search” before. If every component writes it from scratch:

  • Some people forget to clear the timer;

  • Some people write stale closures;

  • Some people get tangled up syncing intermediate state.

Encapsulating it into a hook actually unifies the correct approach:

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

Then the business component can simply write:

const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);

That way, the caller doesn't need to rethink the boundaries of the debounce effect. This is the true value of custom hooks: front-load the judgment cost once, and everyone after just uses it.

6. The 3 most worthwhile basic hooks to solidify in your project

This section doesn't aim for fancy tricks — it covers three hooks that are easiest to implement and best demonstrate design sense.

1. usePrevious

function usePrevious<T>(value: T) {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

This hook is great for comparison logic, for example:

  • Checking if a value just changed

  • Triggering simple animations

  • Comparing previous and current props

Its value isn't just code reuse — it extracts the semantics of “previous value”.

2. useDebouncedValue

function useDebouncedValue<T>(value: T, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(id);
  }, [value, delay]);

  return debouncedValue;
}

This hook is perfect for:

  • Search boxes

  • Form interactions

  • Deferred requests after frequent input

It extracts the “time semantic of debounce” from the component.

3. useEventCallback / latest-callback pattern

function useEventCallback<T extends (...args: any[]) => any>(fn: T) {
  const fnRef = useRef(fn);

  useEffect(() => {
    fnRef.current = fn;
  }, [fn]);

  return useCallback((...args: Parameters<T>) => {
    return fnRef.current(...args);
  }, []);
}

The use case for this hook is:

  • You want a stable function reference;

  • It always executes the latest logic inside.

This pattern is particularly useful for third-party event systems, timer utilities, and subscription bridges.

But a reminder:

  • It's a utility pattern, not the default for business components.

  • In normal scenarios, just writing dependencies honestly is often simpler.

7. When should you NOT extract a custom hook?

This section is important. Just because a component has two useStates doesn't mean you have to extract a hook.

Here are some situations where extraction is usually not worth it:

1. The logic is very short and only appears once in the current component

For example:

const [open, setOpen] = useState(false);

Extracting it into useModalOpen() would be over-engineering. It doesn't form a real capability boundary — it just wraps simple logic in a name.

2. The extracted interface is harder to understand than the original component

If a hook requires:

  • 8 parameters

  • 12 return values

  • A bunch of options configuration

Then you most likely aren't abstracting — you're just hiding complexity. The complexity didn't disappear; it just moved elsewhere.

3. The logic is tightly coupled to the page structure, and there is no stable boundary yet

Some things currently belong only to this page, and you haven’t figured out how they will evolve. In such cases, it's better to keep them in the component first, rather than rushing to extract a half-baked hook.

The right time to extract a hook is usually when “the boundary has emerged”, not when “the code has just gotten longer — let's split it immediately”.

8. A complete refactoring example: from page controller to custom hook

Let's first look at a common page code:

function UserSearchPage() {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, 400);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

This code is still relatively clear, but as requirements grow, it will quickly get heavier. At this point we can extract it into:

function useUserSearch(delay = 400) {
  const [query, setQuery] = useState('');
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const debouncedQuery = useDebouncedValue(query, delay);

  useEffect(() => {
    if (!debouncedQuery) {
      setUsers([]);
      return;
    }

    let ignore = false;
    setLoading(true);
    setError(null);

    fetch(`/api/users?q=${debouncedQuery}`)
      .then(res => res.json())
      .then(data => {
        if (!ignore) setUsers(data);
      })
      .catch(err => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, [debouncedQuery]);

  const reset = useCallback(() => {
    setQuery('');
    setUsers([]);
    setError(null);
    setLoading(false);
  }, []);

  return {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  };
}

And then the page becomes:

function UserSearchPage() {
  const {
    query,
    setQuery,
    users,
    loading,
    error,
    reset,
  } = useUserSearch();

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <button onClick={reset}>Clear</button>
      {loading && <div>Loading...</div>}
      {error && <div>{error.message}</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

The biggest benefit after this refactoring is not “less code”, but that the page finally looks like a page again:

  • It handles presentation.

  • useUserSearch handles state capability.

This is the natural division of labor between a component and a hook.

9. Takeaways from this article

  1. The goal of custom hooks is not just to reuse code, but to reuse the boundary and semantics of a piece of stateful logic.

  2. The right time to extract a hook is usually when the logic has formed a complete capability, or when you want to turn a component back into a presentation layer.

  3. When extracting a hook, split by “problem boundaries”, not by technical points like state/effect/callback.

  4. A hook's public API should prioritize exposing “state + actions”, and avoid exposing internal setters.

  5. A good custom hook should embed the correct mental models from previous articles, rather than just moving complexity around.

Share