Custom React Hooks - From Basic to “Wait, You Can Do That?”

Building Reusable Logic That Actually Makes Sense

Let’s move beyond useState and useEffect tutorials and build custom hooks that solve real problems without turning into unmaintainable spaghetti code.

The Custom Hook I Wish I’d Written Sooner

Three months into my first React job, I was copy-pasting the same data fetching logic across 15 different components. Same pattern every time: useState, useEffect, loading states, error handling.

A senior dev saw my PR and asked: “Have you heard of custom hooks?”

That moment changed how I write React.

What Makes a Good Custom Hook?

  1. Solves a real problem (not just “because I can”)
  2. Has a single responsibility (does one thing well)
  3. Is reusable (works in multiple contexts)
  4. Has a clear API (easy to understand what it returns)
  5. Handles edge cases (cleanup, error states, race conditions)

The Data Fetching Hook

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

  useEffect(() => {
    let isCancelled = false;  // Race condition handling

    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, options);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();

        if (!isCancelled) {
          setData(result);
          setLoading(false);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    };

    fetchData();
    return () => { isCancelled = true; };
  }, [url, JSON.stringify(options)]);

  return { data, loading, error };
}

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

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <div>{user.name}</div>;
}

The LocalStorage Hook

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  // Listen for changes in other tabs
  useEffect(() => {
    const handleStorageChange = (e) => {
      if (e.key === key && e.newValue) {
        setStoredValue(JSON.parse(e.newValue));
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setStoredValue];
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

Essential Hooks Collection

useDebounce

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

usePrevious

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

useMediaQuery

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() =>
    typeof window !== 'undefined' ? window.matchMedia(query).matches : false
  );

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    const handleChange = () => setMatches(mediaQuery.matches);

    handleChange();
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [query]);

  return matches;
}

useIntersectionObserver (Lazy Loading & Infinite Scroll)

function useIntersectionObserver(options = {}) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const elementRef = useRef(null);

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);

    observer.observe(element);
    return () => observer.disconnect();
  }, [options.threshold, options.root, options.rootMargin]);

  return [elementRef, isIntersecting];
}

Common Mistakes

Mistake 1: Object Dependencies

// DON'T - options is new every render
function useFetch(url, options) {
  useEffect(() => {
    fetch(url, options);
  }, [url, options]);
}

// DO - use ref for stable reference
function useFetch(url, options) {
  const optionsRef = useRef(options);

  useEffect(() => {
    optionsRef.current = options;
  });

  useEffect(() => {
    fetch(url, optionsRef.current);
  }, [url]);
}

Mistake 2: Not Cleaning Up

// DON'T
function useInterval(callback, delay) {
  useEffect(() => {
    setInterval(callback, delay);  // Memory leak!
  }, [callback, delay]);
}

// DO
function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    return () => clearInterval(id);
  }, [callback, delay]);
}

When NOT to Use Custom Hooks

  1. It’s just a regular function - If it doesn’t use hooks, it’s just a function
  2. It’s only used once - Don’t over-abstract
  3. It’s too specific - useHandleButtonClickForModalOnDashboard is too specific
  4. It’s too generic - useData that tries to handle everything

My Starter Pack

export { default as useFetch } from './useFetch';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDebounce } from './useDebounce';
export { default as useMediaQuery } from './useMediaQuery';
export { default as usePrevious } from './usePrevious';
export { default as useIntersectionObserver } from './useIntersectionObserver';

These six hooks solve 90% of common React problems.


Custom hooks are one of React’s superpowers. They let you extract complex logic, make it reusable, and keep your components clean. Start with simple hooks, build your utility library, and watch your code quality improve.

Happy hooking!