Skip to content

Custom Hooks in React

Cover image for Custom Hooks in React

You know what’s worse than copy-pasting the same logic across five components? Realizing you have a bug in that logic and now you get to fix it in five places.

Custom hooks exist precisely so you don’t have to live that nightmare. They let you extract component logic into reusable functions.

What Even Is a Custom Hook?

A custom hook is just a JavaScript function whose name starts with use and that can call other hooks.

// This is a custom hook. Riveting, I know.
function useMyFancyHook() {
  const [something, setSomething] = useState(null);
  // do stuff
  return something;
}

The use prefix isn’t just a naming convention, it tells React’s linting rules to check for hook violations.

Why Would You Want One?

Let’s say you’ve written the same useState + useEffect combo to fetch data in three different components. Your code looks like this:

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
        // silently cry
      });
  }, []);

  // render stuff
}

And then you do the exact same thing in DashboardPage. And SettingsPage.

Your First Custom Hook: useFetch

Let’s extract that mess into something dignified:

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

  useEffect(() => {
    let isMounted = true; // because React hates memory leaks and so should you

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('The server said no');
        return res.json();
      })
      .then(data => {
        if (isMounted) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (isMounted) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      isMounted = false; 1
    };
  }, [url]);

  return { data, loading, error };
}

Now your components can be blissfully ignorant of fetching mechanics:

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

  if (loading) return <p>Loading... (dramatic pause)</p>;
  if (error) return <p>Something went wrong. Shocking, I know.</p>;

  return <h1>Hello, {user.name}!</h1>;
}

Your component is now about what it shows, not how it fetches.

The Rules

Custom hooks follow the same rules as regular hooks:

  1. Only call hooks at the top level. Not inside loops, conditions, or nested functions. React needs a predictable call order.

  2. Only call hooks from React functions. Either from functional components or from other custom hooks.

A Slightly More Interesting Example: useLocalStorage

Let’s build a hook that persists state to localStorage. Because sometimes you want things to survive a page refresh.

function useLocalStorage(key, initialValue) {
  // Get stored value or use initial
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // localStorage being quirky? Pretend it never happened.
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Update localStorage when state changes
  const setValue = (value) => {
    try {
      // Allow value to be a function (like regular useState)
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      // Failing silently is an art form
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue];
}

Usage is delightfully simple:

function ThemeToggle() {
  const [darkMode, setDarkMode] = useLocalStorage('darkMode', false);

  return (
    <button onClick={() => setDarkMode(prev => !prev)}>
      {darkMode ? 'Dark' : 'Light'}
    </button>
  );
}

Hooks That Return More Than Just State

Custom hooks can return whatever you want — objects, arrays, functions, your hopes, and dreams, and—. Here’s a useToggle hook that returns the state and a toggle function:

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue(v => !v);
  }, []); // useCallback because 

  return [value, toggle];
}
function Accordion() {
  const [isOpen, toggleOpen] = useToggle(false);

  return (
    <div>
      <button onClick={toggleOpen}>
        {isOpen ? 'Hide' : 'Show'} Content
      </button>
      {isOpen && <p>Surprise! Content exists.</p>}
    </div>
  );
}

When to Create a Custom Hook

Ask yourself:

  • Am I copying and pasting this logic across components?
  • Is this logic complex enough that extracting it would improve readability?
  • Would future-me appreciate having this in one place?

If you answered “yes” to any of these, you might have a hook on your hands. If you answered “no” to all of them… maybe you’re overthinking it. Not everything needs to be a hook. Some things can just be… code.

Common Mistakes (Learn From My Suffering)

1. Forgetting the use prefix

// Wrong - React's linter will ignore this
function fetchData() {
  const [data, setData] = useState(null); // Linter: "I don't know her"
}

// Right - Linter is now paying attention
function useFetchData() {
  const [data, setData] = useState(null); // Linter: "There she is!"
}

2. Not handling cleanup

// Memory leak waiting to happen
useEffect(() => {
  fetch(url).then(res => setData(res)); // Component unmounts, fetch returns
}, [url]);

// Fixed
useEffect(() => {
  let cancelled = false;
  fetch(url).then(res => {
    if (!cancelled) setData(res);
  });
  return () => { cancelled = true; }; // We are responsible now
}, [url]);

3. Over-engineering

Not every three lines of code need to be a hook. If you’re creating useCounter just to wrap useState(0), don’t. The code is fine as it is.

Now go forth and hook responsibly.