Writing Custom Hooks in React: Patterns, Pitfalls, and When to Reach for One

Writing Custom Hooks in React: Patterns, Pitfalls, and When to Reach for One

A practical guide to writing custom React Hooks: the patterns they replaced, the rules they must follow, when to extract one, and libraries that cover the rest.

Aurora Scharff

Aurora Scharff

May 21, 2026

Custom Hooks let you share stateful logic between components. They've become the default way to reuse behavior in React, replacing older patterns like higher-order components, render props, and container/presentational components. In this post, we'll look at what custom Hooks replaced and why, the rules they have to follow, when to extract one, when not to, and where to find ready-made ones for common cases.

What Is a Custom Hook?

A custom Hook is a function whose name starts with use and that calls other Hooks. Here's a tiny one:

      function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}

    

Now any component can use it:

      function Counter() {
  const { count, increment } = useCounter();
  return <button onClick={increment}>{count}</button>;
}

    

The use prefix isn't decorative. Because custom Hooks run as part of React's render, they have to follow the Rules of React and the Rules of Hooks: they must be pure, they must not have side effects during render, they must not mutate values they didn't create, and they must call other Hooks at the top level. The same rules that apply to components apply to your Hooks. We'll come back to these in detail below.

Each call to a custom Hook is independent. Two components calling useCounter() each get their own state. Custom Hooks share logic, not state. If you need to share the actual value, lift state up or use Context.

The Patterns Custom Hooks Replaced

Before Hooks were introduced in React 16.8, sharing stateful logic between components required indirect patterns. Each one had its tradeoffs, and custom Hooks now handle most of them more cleanly.

Higher-Order Components

A higher-order component is a function that takes a component and returns a new one with extra behavior:

      function withLoader(Component, url) {
  return function LoaderComponent(props) {
    const [data, setData] = useState(null);

    useEffect(() => {
      fetch(url).then(res => res.json()).then(setData);
    }, []);

    if (!data) return <div>Loading...</div>;
    return <Component {...props} data={data} />;
  };
}

const UserList = withLoader(List, '/api/users');

    

The same logic as a custom Hook:

      function useLoader(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData);
  }, [url]);
  return data;
}

function UserList() {
  const data = useLoader('/api/users');
  if (!data) return <div>Loading...</div>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

    

No wrapper component, no "wrapper hell" in the React DevTools, and the data flows where the consumer wants it.

Render Props

Render props pass a function as a prop (often children, but it can be any prop name) and call it with the shared values:

      function MouseTracker({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);

  return children(position);
}

<MouseTracker>
  {({ x, y }) => <div>Mouse: {x}, {y}</div>}
</MouseTracker>

    

The Hook version is flatter:

      function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);

  return position;
}

function App() {
  const { x, y } = useMousePosition();
  return <div>Mouse: {x}, {y}</div>;
}

    

No callback nesting, and you can combine multiple Hooks side by side without the "render-prop pyramid".

Container/Presentational

The container/presentational split separated data-fetching logic into a "container" component and rendering into a "presentational" one:

      function UserProfileContainer({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
  }, [userId]);

  if (!user) return <div>Loading...</div>;
  return <UserProfile user={user} />;
}

    

A custom Hook achieves the same separation without an extra component:

      function useUser(userId) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
  }, [userId]);
  return user;
}

function UserProfile({ userId }) {
  const user = useUser(userId);
  if (!user) return <div>Loading...</div>;
  return <div><h2>{user.name}</h2><p>{user.email}</p></div>;
}

    

Logic and presentation stay separate, but there's no wrapper to thread props through.

Following the Rules of React

Custom Hooks have to follow the same rules as the built-in ones. There are two layers worth knowing.

The Rules of Hooks

These govern how Hooks are called:

  • Only call Hooks at the top level. Never inside conditions, loops, nested functions, try/catch/finally blocks, or after early returns.
  • Only call Hooks from React function components or other custom Hooks. Not from regular JavaScript functions, event handlers, or class components.
      function useUser(id) {
  if (!id) return null; // ❌ Early return before Hooks
  const [user, setUser] = useState(null);
}

    

The eslint-plugin-react-hooks plugin catches violations automatically. If you feel like you need to call a Hook conditionally, restructure the component instead.

The Rules of React

The broader Rules of React apply to the body of your Hook just like they apply to a component. Two matter most for custom Hooks:

Hooks must be pure. The same inputs should produce the same output. Don't mutate props, state, or any value you didn't create during render, and don't perform side effects:

      // ❌ Impure: mutates the caller's array during render
function useSortedItems(items) {
  items.sort();
  return items;
}

// ✅ Pure: returns a new array
function useSortedItems(items) {
  return [...items].sort();
}

    

Side effects belong inside useEffect(), event handlers, or refs. Never in the body of a Hook itself. React will call your Hook more than you expect (twice in development under StrictMode, plus concurrent renders that get discarded), and impurity surfaces as bugs that are hard to reproduce. For more on this, see Component Purity and StrictMode in React.

React calls your Hook, not you. Never call a custom Hook from a regular function, and never pass one around as a value. React tracks state by call order, so the calling pattern has to stay consistent.

When to Extract a Custom Hook

The React docs are clear: don't add the use prefix to a function just because it's reusable. Extract a Hook when one of these is true:

  • You're duplicating stateful logic across components
  • You're synchronizing with an external system (browser APIs, subscriptions, libraries)
  • You want to hide complexity behind a clear name

The classic example is tracking online status:

      // Before: logic mixed into the component
function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return <h1>{isOnline ? 'Online' : 'Offline'}</h1>;
}

    

Extracted into a Hook:

      function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? 'Online' : 'Offline'}</h1>;
}

    

The status logic now lives in one place where any component can reuse it.

When Not to Extract a Custom Hook

Some extractions look helpful but aren't.

Avoid wrapping a single useState. A useToggle() Hook that returns [value, () => setValue(v => !v)] saves one line and adds an extra layer. Usually not worth it.

Avoid lifecycle Hooks like useMount():

      // ❌ Don't do this
function useMount(fn) {
  useEffect(() => { fn(); }, []);
}

    

This hides dependencies from the linter and pretends React has a lifecycle when it really works through synchronization. Write the Effect directly so the linter can verify your dependencies.

Don't use the use prefix on pure helpers. Functions that don't call any Hooks shouldn't start with use. A formatter like formatCurrency() or a calculator like sumPrices() is just a regular function. The linter relies on the use prefix to know where the Rules of Hooks apply.

Composing Hooks from Hooks

Custom Hooks compose. Build small, focused primitives and combine them into bigger ones.

Start with a generic useLocalStorage():

      function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

    

Then build useTheme() on top of it:

      function useTheme() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
  return { theme, toggleTheme };
}

    

Each Hook stays simple, and the higher-level Hook reads like a description of what it does.

What About Wrapping Returned Functions in useCallback?

A common question when designing custom Hooks: should you wrap every returned function in useCallback() so its identity stays stable across renders?

The older answer was yes. If the function gets passed into a dependency array or to a memoized child component, an unstable reference would cause unnecessary re-renders or Effect re-runs.

With the React Compiler, now stable, the answer changes. The Compiler memoizes your code automatically. From the official docs:

By default, React Compiler will memoize your code based on its analysis and heuristics. In most cases, this memoization will be as precise, or moreso, than what you may have written.

When the Compiler is enabled, you generally don't need to wrap functions returned from custom Hooks in useCallback(). The Compiler analyzes the Hook and produces equivalent memoization. The docs recommend relying on the Compiler for new code, and only reaching for useMemo() and useCallback() as an escape hatch when you need explicit, precise control.

If the Compiler isn't enabled in your project yet, the older rule still applies: wrap returned functions with useCallback() when their identity matters to consumers. For a deeper look, see React Compiler: No More useMemo and useCallback.

A Note on Event Handler Props

If your Hook accepts a callback from the consumer and uses it inside an Effect, the Effect will re-run every time the parent re-renders. The new useEffectEvent() API, stabilized in React 19.2, solves this by letting you read the latest callback without making the Effect reactive to it:

      function useChatRoom({ roomId, onReceiveMessage }) {
  const onMessage = useEffectEvent(onReceiveMessage);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', msg => onMessage(msg));
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

    

Reach for it whenever your custom Hook accepts an event handler prop.

Don't Reinvent the Wheel

Before writing a custom Hook for a common need, check if one already exists. Several well-maintained libraries provide ready-to-use Hooks for the cases you'll hit most often:

  • useHooks: a curated collection covering useDebounce, useLocalStorage, useMediaQuery, useClickOutside, and many more. Copy-paste snippets you can adapt to your codebase.
  • React Use: a large npm package with 100+ Hooks for sensors (mouse, scroll, geolocation), UI state, side effects, and animations.
  • usehooks-ts: TypeScript-first collection with strong typing out of the box.
  • Collection of React Hooks: a community-maintained directory of Hook libraries grouped by category.

These libraries cover the bulk of "I need a Hook that does X" situations. Reach for one of them before writing your own, especially for things like debouncing, media queries, or click-outside detection. Save your custom Hooks for logic that's specific to your application.

Key Takeaways

  • Custom Hooks share logic, not state. Each call is independent.
  • They replaced higher-order components, render props, and container/presentational splits with a flatter, more composable pattern.
  • Follow the Rules of Hooks (top level only, React functions only) and the Rules of React (keep Hooks pure, no side effects during render).
  • Extract a Hook when you're duplicating stateful logic, syncing with an external system, or hiding complexity behind a name.
  • Avoid trivial wrappers, lifecycle Hooks like useMount(), and the use prefix on pure helpers.
  • Compose small Hooks into bigger ones. useLocalStorage() becomes useTheme().
  • With the React Compiler enabled, you generally don't need to wrap functions returned from custom Hooks in useCallback(). The Compiler handles memoization for you.
  • Check libraries like useHooks, React Use, and usehooks-ts before writing your own. They already cover the common cases.

Conclusion

Custom Hooks are useful, but the best ones stay simple. Write the inline code first, extract a Hook the second time you reach for the same logic, and check the ecosystem before building something that already exists.

For more on the patterns custom Hooks replaced, see React Children and cloneElement: Component Patterns from the Docs.


Sources:

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)