React Custom Hooks

Custom Hooks let you pull component logic out into reusable functions that start with the word 'use'.

Why Custom Hooks?

As components grow, you often find the same stateful logic repeated across several of them: tracking window size, subscribing to an event, debouncing a value, or fetching data. Copy-pasting that logic between components works, but it duplicates bugs along with the behavior. A custom Hook lets you extract that logic once and share it anywhere, without changing the shape of your component tree the way a higher-order component or render-prop wrapper would.

A custom Hook is nothing more than a JavaScript function whose name starts with 'use' and which may call other Hooks (useState, useEffect, useRef, or other custom Hooks) inside it. That naming convention is not just style — the linter plugin 'eslint-plugin-react-hooks' uses it to verify that the Rules of Hooks are respected.

The Rules of Hooks still apply

  • Only call Hooks at the top level — never inside loops, conditions, or nested functions.
  • Only call Hooks from React function components or from other custom Hooks.
  • A custom Hook's name must start with 'use' so tooling and other developers recognize it.
  • Each component that calls a custom Hook gets its own isolated state — nothing is shared unless you design it to be.

Extracting your first custom Hook

Imagine two components that both need to know whether the browser is online. Instead of duplicating a useState/useEffect pair in both, extract it into useOnlineStatus.

A useOnlineStatus custom Hook

import { useState, useEffect } from 'react';

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const goOnline = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);

    window.addEventListener('online', goOnline);
    window.addEventListener('offline', goOffline);

    return () => {
      window.removeEventListener('online', goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []);

  return isOnline;
}

function StatusBadge() {
  const isOnline = useOnlineStatus();
  return <span>{isOnline ? '🟢 Online' : '🔴 Offline'}</span>;
}

export default StatusBadge;

Notice that StatusBadge doesn't know or care how the online status is tracked. It just calls a function and gets a value back, exactly like calling useState. That's the core idea: custom Hooks let components describe *what* they need, while the Hook handles *how*.

Passing parameters and returning values

Custom Hooks are ordinary functions, so they can accept arguments and return anything — a single value, an array (like useState does), or an object of named values. Returning an object is often clearer once you have more than two values to expose.

A parameterized useDebounce Hook

import { useState, useEffect } from 'react';

function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      console.log('Searching for:', debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />
  );
}

export default SearchBox;
Note: A custom Hook does not share state between the components that call it. Two components calling useDebounce independently each get their own timer and their own debounced value — the Hook only shares the *logic*, not the data.

Composing Hooks together

One of the biggest advantages over older patterns like mixins or higher-order components is that custom Hooks compose cleanly: a custom Hook can call another custom Hook, which can call another, without wrapping your JSX in extra layers of components.

Composing useFetch on top of useDebounce

import { useState, useEffect } from 'react';

function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then((json) => { if (!cancelled) { setData(json); setLoading(false); } });
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading };
}

function LiveSearchResults({ query }) {
  const debouncedQuery = useDebounce(query, 400);
  const { data, loading } = useFetch(`/api/search?q=${debouncedQuery}`);

  if (loading) return <p>Loading…</p>;
  return <ul>{data?.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}

function App() {
  const [query, setQuery] = useState('');

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
      />
      <LiveSearchResults query={query} />
    </div>
  );
}

export default App;
PatternReuses logic?Adds wrapper components?Still common today?
MixinsYesNoNo — removed from React
Higher-order componentsYesYes, extra layer per HOCRarely, legacy code only
Render propsYesYes, extra nesting in JSXRarely
Custom HooksYesNoYes — the standard approach
Note: A custom Hook is a naming convention enforced by discipline, not by the JavaScript engine. Naming a function 'useSomething' when it doesn't call any Hooks internally works fine, but naming a Hook-calling function something that doesn't start with 'use' will break the linter's ability to check the Rules of Hooks for it.

Exercise: React Custom Hooks

Why must a custom hook's function name start with the word 'use'?