Asher Cohen
Back to posts

Custom React Hooks Patterns

Reusable logic patterns with custom React hooks for cleaner, more maintainable components

Introduction

Custom hooks are one of React's most powerful features. They let you extract component logic into reusable functions, promoting code reuse and cleaner components.

What Are Custom Hooks?

Custom hooks are JavaScript functions that start with use and call other hooks.

// Custom hook for tracking window size
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

Common Patterns

useGetLatest

Keep the latest value of a variable without causing re-renders.

function useGetLatest(value) {
  const ref = useRef();
  ref.current = value;
  return useCallback(() => ref.current, []);
}

// Usage
function Counter() {
  const [count, setCount] = useState(0);
  const getLatestCount = useGetLatest(count);

  const logCount = () => {
    console.log(getLatestCount()); // Always latest
  };

  return <button onClick={logCount}>Log</button>;
}

useAsyncDebounce

Debounce async operations to prevent excessive calls.

function useAsyncDebounce(defaultFn, defaultWait = 0) {
  const debounceRef = useRef({});
  const getDefaultFn = useGetLatest(defaultFn);
  const getDefaultWait = useGetLatest(defaultWait);

  return useCallback(
    async (...args) => {
      if (!debounceRef.current.promise) {
        debounceRef.current.promise = new Promise((resolve, reject) => {
          debounceRef.current.resolve = resolve;
          debounceRef.current.reject = reject;
        });
      }

      if (debounceRef.current.timeout) {
        clearTimeout(debounceRef.current.timeout);
      }

      debounceRef.current.timeout = setTimeout(async () => {
        delete debounceRef.current.timeout;
        try {
          debounceRef.current.resolve(await getDefaultFn()(...args));
        } catch (err) {
          debounceRef.current.reject(err);
        } finally {
          delete debounceRef.current.promise;
        }
      }, getDefaultWait());

      return debounceRef.current.promise;
    },
    [getDefaultFn, getDefaultWait],
  );
}

// Usage
function SearchBox() {
  const [results, setResults] = useState([]);

  const search = useAsyncDebounce(async (query) => {
    const response = await fetch(`/api/search?q=${query}`);
    return response.json();
  }, 300);

  const handleChange = async (e) => {
    const results = await search(e.target.value);
    setResults(results);
  };

  return <input onChange={handleChange} />;
}

useLocalStorage

Persist state in localStorage.

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;
    }
  });

  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

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

  const toggle = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return <button onClick={toggle}>{theme}</button>;
}

useFetch

Simplify data fetching with built-in loading and error states.

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

  useEffect(() => {
    const controller = new AbortController();

    const fetchData = async () => {
      try {
        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        if (!response.ok) throw new Error('Network error');

        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{user.name}</div>;
}

Best Practices

Follow Hook Rules

  • Only call hooks at the top level
  • Only call hooks from React functions
  • Name custom hooks with use prefix

Keep Hooks Focused

Each hook should do one thing well.

// ✅ Focused
function useUser() {
  /* ... */
}
function usePosts() {
  /* ... */
}

// ❌ Too broad
function useEverything() {
  /* user, posts, comments, settings... */
}

Document Your Hooks

/**
 * useLocalStorage - Persist state in localStorage
 * @param {string} key - localStorage key
 * @param {any} initialValue - Initial value if key doesn't exist
 * @returns {[any, function]} Current value and setter function
 */
function useLocalStorage(key, initialValue) {
  // ...
}

Conclusion

Custom hooks are essential for building reusable, maintainable React applications. Master these patterns to write cleaner components.

#react #hooks #javascript