Skip to content
React rc hooks 5 min read

Custom Hooks

A custom hook is just a JavaScript function whose name starts with use and that calls one or more built-in hooks. Custom hooks let you lift repetitive stateful logic out of components and reuse it everywhere, without the wrapper-component nesting that older patterns like render props or higher-order components imposed. They are the idiomatic way to share behavior in modern React, keeping your components lean and focused on rendering.

What makes a function a hook

Two rules turn an ordinary function into a hook. First, its name must begin with use so that React’s linter and runtime can verify the Rules of Hooks are followed. Second, it must call at least one other hook (built-in or custom). A helper that just transforms data is a plain function, not a hook, and naming it use* would be misleading.

Custom hooks are subject to the same Rules of Hooks as components: call them only at the top level of a component or another hook, never inside conditions, loops, or nested functions.

import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle, setOn];
}

Using it in a component reads almost like a built-in hook:

function Panel() {
  const [open, toggleOpen] = useToggle();
  return (
    <div>
      <button onClick={toggleOpen}>{open ? 'Hide' : 'Show'}</button>
      {open && <p>Now you see me.</p>}
    </div>
  );
}

Hooks share logic, not state

A crucial point: every component that calls a custom hook gets its own independent state. The hook describes how state behaves, not a shared store. If two components each call useToggle(), toggling one does not affect the other. To share actual state, lift it up or use Context or a store consumed via useSyncExternalStore.

useLocalStorage

Persisting state to localStorage is a common need. This hook mirrors the useState API but syncs the value to storage on every change.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      /* storage may be unavailable (private mode, quota) */
    }
  }, [key, value]);

  return [value, setValue];
}
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Theme: {theme}
    </button>
  );
}

The lazy initializer reads storage only once on mount, and the effect writes back whenever value or key changes. Reloading the page restores the last saved theme.

useDebounce

Debouncing delays a value until updates stop arriving, which is ideal for search inputs. Note the cleanup function: it clears the pending timer whenever the value changes again before the delay elapses.

import { useState, useEffect } from 'react';

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

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

  return debounced;
}
function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

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

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

Output:

Searching for: react hooks

(Logged once, 400 ms after the user stops typing — not on every keystroke.)

useFetch

Data fetching combines state, an effect, and cleanup to avoid setting state after the component unmounts or the URL changes. An AbortController cancels the in-flight request.

import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [state, setState] = useState({ data: null, error: null, loading: true });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, error: null, loading: true });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((error) => {
        if (error.name !== 'AbortError') {
          setState({ data: null, error, loading: false });
        }
      });

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

  return state;
}
function UserCard({ id }) {
  const { data, error, loading } = useFetch(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <h2>{data.name}</h2>;
}

Tip: For production data fetching, prefer a dedicated library such as TanStack Query or SWR. They build on these same patterns but add caching, deduplication, and revalidation that a hand-rolled useFetch lacks.

Naming and return shape

Choose return shapes that match how the hook is consumed. Return a tuple [value, setter] when the order is obvious (like useState), and an object { data, error, loading } when callers want to destructure by name. Keep handlers stable with useCallback so consumers can safely list them in dependency arrays.

Return shapeBest forExample
Array tupleTwo related values, positionalconst [on, toggle] = useToggle()
ObjectSeveral named fieldsconst { data, loading } = useFetch(url)
Single valueDerived/computed resultconst debounced = useDebounce(q)

Best Practices

  • Prefix every hook with use so the linter enforces the Rules of Hooks.
  • Extract a custom hook only when logic is genuinely reused or when it clarifies a component — premature abstraction adds indirection.
  • Remember each call has independent state; use Context or a store for truly shared state.
  • Memoize returned functions with useCallback and computed values with useMemo when consumers depend on referential stability.
  • Always clean up timers, subscriptions, and requests inside the hook’s effect to prevent leaks.
  • Keep hooks focused on one concern; compose small hooks instead of building one giant hook.
  • Accept configuration through arguments (delays, keys, URLs) so a hook stays reusable across contexts.
Last updated June 14, 2026
Was this helpful?