codelessgenie guide

How to Create a Custom Hook in React

Before diving into custom hooks, let’s recap what React hooks are. Introduced in React 16.8, hooks are functions that allow you to "hook into" React state and lifecycle features from functional components. Prior to hooks, state management and side effects (e.g., data fetching, subscriptions) were handled in class components using `this.state` and lifecycle methods like `componentDidMount`. React provides several built-in hooks, including: - `useState`: Manages state in functional components. - `useEffect`: Handles side effects (e.g., data fetching, DOM updates). - `useContext`: Accesses context values. - `useReducer`: Manages complex state logic with a reducer function. - `useCallback`/`useMemo`: Optimizes performance by memoizing functions/values. While these built-in hooks solve many problems, they don’t address **reusable stateful logic**. For example, if two components both need to sync state with `localStorage`, validate form inputs, or fetch data with loading/error states, you’d end up copying the same logic across components. This is where custom hooks come in.

React has revolutionized how we build user interfaces, and one of its most powerful features is Hooks—functions that let you use state and other React features without writing a class. While React provides built-in hooks like useState and useEffect, there are times when you’ll find yourself repeating stateful logic across multiple components. This is where custom hooks shine: they let you extract and reuse stateful logic across components, making your code cleaner, more maintainable, and easier to test.

In this guide, we’ll dive deep into custom hooks—what they are, why they matter, how to build them, and best practices to avoid common pitfalls. By the end, you’ll be equipped to create your own custom hooks to solve real-world problems.

Table of Contents

What Are Custom Hooks?

A custom hook is a JavaScript function whose name starts with use and may call other hooks (built-in or custom). Custom hooks let you extract component logic into reusable functions, enabling you to share stateful logic across multiple components without duplicating code.

Key Characteristics:

  • Name Convention: Must start with use (e.g., useLocalStorage, useFetch). This allows React to enforce hook rules (more on that later).
  • Reuses Logic, Not State: Custom hooks share logic, but each component that uses a custom hook gets its own isolated state. Two components using useLocalStorage won’t share the same localStorage key unless explicitly designed to.
  • Composable: Can call other hooks (built-in or custom) to build complex logic from simpler pieces.

Benefits of Custom Hooks

Custom hooks offer several advantages for React development:

1. Reusability

Extract repeated stateful logic (e.g., data fetching, form validation) into a single hook, then reuse it across components, pages, or even projects.

2. Separation of Concerns

Cleanly separate component UI logic from “utility” logic (e.g., localStorage sync, window resizing). Components become focused on rendering, while hooks handle the “how” of state management.

3. Readability

Descriptive hook names (e.g., useFormValidation, useGeolocation) make code self-documenting. Instead of reading through 50 lines of state and effect logic in a component, you see const { isValid, errors } = useFormValidation(formData);.

4. Testability

Custom hooks are pure functions, making them easy to test in isolation (no need to render a component to test the logic).

Rules for Creating Custom Hooks

Custom hooks must follow React’s Rules of Hooks, which ensure hooks work correctly:

1. Only Call Hooks at the Top Level

Don’t call hooks inside loops, conditions, or nested functions. Hooks need to be called in the same order every time a component renders to ensure React can track state correctly.

2. Only Call Hooks from React Functions

Call hooks from:

  • React functional components.
  • Other custom hooks.

Never call hooks from regular JavaScript functions (e.g., event handlers, utility functions).

3. Name Must Start with use

This convention is enforced by React’s linter (via eslint-plugin-react-hooks), which checks for hook rule violations. Without this, React can’t distinguish custom hooks from regular functions, leading to bugs.

Step-by-Step Guide to Creating a Custom Hook

Let’s walk through building a custom hook from scratch. We’ll use useLocalStorage as an example—a hook that syncs state with localStorage, a common use case.

Step 1: Identify Repeated Logic

First, identify stateful logic that’s duplicated across components. For example, here’s a component that syncs a theme state with localStorage:

// ThemeComponent.js
import { useState, useEffect } from 'react';

function ThemeComponent() {
  // Initialize state with localStorage value (or default)
  const [theme, setTheme] = useState(() => {
    const savedTheme = localStorage.getItem('theme');
    return savedTheme ? JSON.parse(savedTheme) : 'light';
  });

  // Sync state with localStorage on change
  useEffect(() => {
    localStorage.setItem('theme', JSON.stringify(theme));
  }, [theme]);

  return (
    <div>
      <p>Current Theme: {theme}</p>
      <button onClick={() => setTheme('light')}>Light</button>
      <button onClick={() => setTheme('dark')}>Dark</button>
    </div>
  );
}

If another component (e.g., UserPreferencesComponent) needs to sync username with localStorage, you’d复制the useState and useEffect logic. Instead, extract this into a custom hook!

Step 2: Extract Logic into a Function Starting with use

Create a function named useLocalStorage (starts with use) and move the localStorage sync logic into it.

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

function useLocalStorage(key, initialValue) {
  // Logic to sync state with localStorage
}

Step 3: Use Built-in Hooks Inside

Inside useLocalStorage, use useState to manage the state and useEffect to sync with localStorage.

function useLocalStorage(key, initialValue) {
  // Initialize state with localStorage value (or initialValue)
  const [value, setValue] = useState(() => {
    try {
      // Get from localStorage by key
      const item = localStorage.getItem(key);
      // Parse stored JSON or return initialValue if null
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // Fallback to initialValue if error (e.g., invalid JSON)
      console.error('Error reading localStorage key:', key, error);
      return initialValue;
    }
  });

  // Update localStorage whenever value changes
  useEffect(() => {
    try {
      // Save to localStorage
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Error writing to localStorage key:', key, error);
    }
  }, [key, value]); // Re-run effect if key or value changes

  // Return state and setter (like useState)
  return [value, setValue];
}

Step 4: Return Values Needed by Components

The hook should return values the component needs. Here, we return [value, setValue]—mimicking the useState API for familiarity.

Step 5: Use the Hook in Components

Now, any component can use useLocalStorage to sync state with localStorage in one line:

// ThemeComponent.js (refactored)
import useLocalStorage from './useLocalStorage';

function ThemeComponent() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <div>
      <p>Current Theme: {theme}</p>
      <button onClick={() => setTheme('light')}>Light</button>
      <button onClick={() => setTheme('dark')}>Dark</button>
    </div>
  );
}

// UserPreferencesComponent.js
import useLocalStorage from './useLocalStorage';

function UserPreferencesComponent() {
  const [username, setUsername] = useLocalStorage('username', 'guest');

  return (
    <div>
      <p>Username: {username}</p>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
    </div>
  );
}

Boom! No more duplicated logic. Both components reuse useLocalStorage to sync state with localStorage.

Practical Examples

Let’s explore three more practical custom hooks to solidify your understanding.

Example 1: useFetch – Simplify Data Fetching

Fetching data with useEffect often requires managing data, loading, and error states. useFetch abstracts this logic.

Hook Implementation:

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

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

  // Memoize fetch function to prevent infinite re-renders
  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [url, options]); // Re-run if url or options change

  // Trigger fetch on mount or when url/options change
  useEffect(() => {
    const controller = new AbortController();
    options.signal = controller.signal; // Allow aborting on unmount

    fetchData();

    // Cleanup: abort fetch if component unmounts
    return () => controller.abort();
  }, [fetchData, options]);

  return { data, loading, error, refetch: fetchData };
}

export default useFetch;

Usage in a Component:

// UserProfile.js
import useFetch from './useFetch';

function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useFetch(
    `https://api.example.com/users/${userId}`
  );

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error} <button onClick={refetch}>Retry</button></p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Example 2: useWindowSize – Track Window Dimensions

Tracking window width/height is useful for responsive UIs. useWindowSize uses useEffect to listen for resize events.

Hook Implementation:

// useWindowSize.js
import { useState, useEffect } from 'react';

function useWindowSize() {
  // Initialize state with window dimensions (or defaults for SSR)
  const [size, setSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 1200,
    height: typeof window !== 'undefined' ? window.innerHeight : 800,
  });

  // Update size on window resize
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    // Add event listener
    window.addEventListener('resize', handleResize);
    // Initial check (in case window size changed before mount)
    handleResize();

    // Cleanup: remove event listener
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty deps: run once on mount

  return size;
}

export default useWindowSize;

Usage in a Component:

// ResponsiveComponent.js
import useWindowSize from './useWindowSize';

function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  return (
    <div>
      <p>Window Size: {width}px × {height}px</p>
      {width < 768 && <p>Mobile view detected!</p>}
    </div>
  );
}

Advanced Tips for Custom Hooks

1. Optimize with useCallback/useMemo

If your custom hook returns functions (e.g., refetch in useFetch), wrap them in useCallback to avoid recreating the function on every render, which can trigger unnecessary re-renders in child components.

2. Compose Custom Hooks

Custom hooks can call other custom hooks to build complex logic. For example, useFormWithLocalStorage could use useForm (for validation) and useLocalStorage (for persistence):

function useFormWithLocalStorage(initialValues, validate) {
  const [values, setValues] = useLocalStorage('formData', initialValues);
  const { errors, isValid } = useFormValidation(values, validate);

  return { values, setValues, errors, isValid };
}

3. Test Custom Hooks

Test custom hooks in isolation using @testing-library/react-hooks, which provides a renderHook utility to simulate hook usage:

// useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';

test('useLocalStorage initializes with localStorage value', () => {
  // Mock localStorage
  localStorage.setItem('testKey', JSON.stringify('testValue'));

  const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
  expect(result.current[0]).toBe('testValue');
});

test('updates localStorage when state changes', () => {
  const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
  
  act(() => {
    result.current[1]('newValue'); // Call setValue
  });

  expect(localStorage.getItem('testKey')).toBe(JSON.stringify('newValue'));
});

Common Pitfalls to Avoid

1. Forgetting the use Prefix

Names that don’t start with use (e.g., localStorageHook) won’t trigger React’s hook rule checks. This can lead to bugs (e.g., calling hooks in conditions). Always start with use!

2. Overcomplicating Hooks

Avoid building “kitchen-sink” hooks that do too much. A good custom hook should solve one specific problem (e.g., useFetch for data fetching, not data fetching + form validation).

3. Neglecting Cleanup

If your hook uses useEffect with side effects (e.g., event listeners, subscriptions), always return a cleanup function to prevent memory leaks:

// Bad: No cleanup
useEffect(() => {
  window.addEventListener('resize', handleResize);
});

// Good: Cleanup
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);

4. Assuming Hooks Share State

Each component using a custom hook gets its own isolated state. Two components using useLocalStorage('theme') will share the same localStorage key, but two components using useLocalStorage('theme1') and useLocalStorage('theme2') will have independent state.

Conclusion

Custom hooks are a powerful way to reuse stateful logic in React, improving code reusability, readability, and maintainability. By following the use naming convention, adhering to hook rules, and focusing on single responsibilities, you can build hooks that simplify your React applications.

The next time you find yourself copying stateful logic between components, ask: “Can this be a custom hook?” Chances are, the answer is yes!

References