React Basics #13: Custom Hooks

8 min read

Last time we covered Context, the tool for solving prop drilling, and at the end we created a function called useTheme to make it easier to use. That useTheme was actually an example of a custom hook. This time we’ll dig into what custom hooks are, why you make them, and how.

The problem of sharing logic between components #

Up to now, you’ve reused code at the component (JSX-returning function) level. But what if what you want to reuse isn’t a piece of UI but a piece of logic?

Take these two components, for example — they repeat almost the same logic.

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  // ... render ...
}
src/PostList.jsx
function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => setPosts(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  // ... render ...
}

The same pattern — data fetching + loading/error state — is duplicated. How do you consolidate this into one place and reuse it? Custom hooks are the answer.

What’s a custom hook? #

A custom hook is just a regular function whose name starts with use and that uses other hooks. That’s the whole definition. There’s no new syntax — it’s just a convention.

a simple custom hook
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(initial);

  return { count, increment, decrement, reset };
}

This function isn’t a component (it doesn’t return JSX). But it uses a hook called useState inside. So it’s a hook itself.

The consumer side:

src/Counter.jsx
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <h2>{count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

The entire counter logic is encapsulated in one line of useCounter. Calling the same hook from another component gives that component its own counter.

Note
“Names start with use” isn’t just a convention. React decides whether a function is a hook by checking if its name starts with use, and applies the hook rules (described below) accordingly. The ESLint plugin react-hooks enforces this same rule. Always start the name with use.

Rules of hooks #

Whether you’re writing a custom hook or using one, every hook follows two rules.

Rule 1. Call hooks only at the top level of a function #

wrong example — inside a conditional
function App() {
  if (someCondition) {
    const [count, setCount] = useState(0);  // no
  }
}

Hooks must be called at the top level of the component function. Don’t call them inside conditionals, loops, or nested functions. React identifies which state belongs to which useState by call order, so the call order must be the same every time.

Rule 2. Call hooks only from React functions #

You can call hooks only from component functions or other custom hooks. Don’t call them from regular JavaScript functions.

wrong example — calling from a regular function
function fetchSomething() {
  const [data, setData] = useState(null);  // no
}

If you break these rules, ESLint catches them and React throws an error at runtime.

Custom hooks people often build #

Here are a few common custom hooks you’ll either build yourself or grab from a library.

useToggle — boolean toggle #

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}
usage
function App() {
  const [isOpen, toggleOpen] = useToggle();

  return (
    <>
      <button onClick={toggleOpen}>{isOpen ? 'Close' : 'Open'}</button>
      {isOpen && <div>Panel content</div>}
    </>
  );
}

Toggling a checkbox, opening/closing a modal, expanding/collapsing a menu — this pattern shows up so often that it’s worth having on hand.

useLocalStorage — sync state ↔ localStorage #

src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

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

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

  return [value, setValue];
}
usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

The usage looks almost identical to useState, but the value is automatically saved to localStorage and survives a page refresh. Keeping an interface that mimics the base hook like this makes the consumer side intuitive.

useDebounce — delay value changes #

Firing a search request on every keystroke hammers the server. Debouncing waits for the user to pause briefly before sending.

src/hooks/useDebounce.js
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;
}
usage
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (!debouncedQuery) return;
    fetch(`/api/search?q=${debouncedQuery}`).then(/* ... */);
  }, [debouncedQuery]);

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

query updates immediately on every keystroke, but debouncedQuery only updates after typing has been quiet for 500ms. As a result, the search request only fires when the user pauses.

The key part is canceling the previous timer in cleanup — when value changes frequently, each change cancels the previous timer and sets a new one, so the value updates only after the last change has been quiet for delay.

useFetch — data fetching #

Let’s extract the duplicated pattern from the introduction into a hook.

src/hooks/useFetch.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`request failed: ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) setData(json);
      })
      .catch(err => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}
usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <p>{user.name}</p>;
}

function PostList() {
  const { data: posts, loading, error } = useFetch('/api/posts');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

The duplication from the introduction is gone, and each component focuses solely on rendering. Even if the same logic is used in 100 places, fixing one hook fixes all 100.

Note
In real-world code, people often use a library like TanStack Query instead of building their own useFetch. It provides nicely polished caching, revalidation, background updates, pagination, and other things that are tricky to implement yourself. But those libraries are, ultimately, custom hooks built with useEffect + useState, so understanding the principles speeds up library learning.

The real value of custom hooks #

The most striking thing about building custom hooks is the freedom of abstraction. What you’ve extracted isn’t just a function — it’s a stateful unit of behavior. You can now treat features like counter, toggle, data fetching, and debounce as independent units, decoupled from components.

Another important point: each component that calls a hook gets its own state. If two components call useCounter(), two separate counts are created — just like with useState. In other words, hooks share code but not state. If two components need to see the same state, you’ll need lifting state up from #11 or Context from #12.

Try it yourself #

Let’s tidy up components from earlier articles using custom hooks.

src/hooks/useToggle.js:

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}

src/hooks/useLocalStorage.js:

src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

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

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

  return [value, setValue];
}

src/App.jsx:

src/App.jsx
import { useToggle } from './hooks/useToggle';
import { useLocalStorage } from './hooks/useLocalStorage';

function App() {
  const [isMenuOpen, toggleMenu] = useToggle();
  const [name, setName] = useLocalStorage('userName', '');

  return (
    <div style={{ padding: '16px' }}>
      <h1>Custom hook demo</h1>

      <section style={{ marginTop: '16px' }}>
        <button onClick={toggleMenu}>{isMenuOpen ? 'Close menu' : 'Open menu'}</button>
        {isMenuOpen && (
          <ul>
            <li>Home</li>
            <li>About</li>
            <li>Contact</li>
          </ul>
        )}
      </section>

      <section style={{ marginTop: '16px' }}>
        <p>Type your name (it survives a page refresh):</p>
        <input value={name} onChange={(e) => setName(e.target.value)} />
        {name && <p>Hello, {name}!</p>}
      </section>
    </div>
  );
}

export default App;

The toggle menu opens and closes on each click, and the name input survives a page refresh. The component code got much shorter, and the toggle/localStorage-sync logic is now reusable everywhere else.

Wrapping up #

In this article we covered custom hooks, the tool for sharing logic between components. To summarize:

  • A custom hook = a function whose name starts with use and uses other hooks
  • Hook rules: call only at function top level, only from a component or another hook
  • Common patterns: useToggle, useLocalStorage, useDebounce, useFetch
  • Hooks share logic but not state (sharing state means lifting/Context)
  • Libraries (TanStack Query, etc.) are themselves built as custom hooks

So far we’ve focused on “how to make it work.” In the next article, “React Basics #14: Performance optimization,” we’ll cover the tools focused on “how to make it run fast” — memo, useMemo, and useCallback. They’re often misused, so we’ll also cover when you should use them and when you shouldn’t.

X