coderain guide

Creating Custom React Hooks: A Developer’s Guide

React hooks, introduced in React 16.8, revolutionized how we manage state and side effects in functional components. While React provides built-in hooks like `useState` and `useEffect`, **custom React hooks** take this a step further by letting you extract reusable logic from components into standalone functions. This guide will walk you through everything you need to know to create, use, and master custom hooks—from core principles to advanced patterns, best practices, and real-world examples.

Table of Contents

  1. Introduction to Custom React Hooks
  2. Why Create Custom React Hooks?
  3. Core Principles of Custom Hooks
  4. Getting Started: Setting Up Your Environment
  5. Step-by-Step: Building Your First Custom Hook
  6. Advanced Custom Hooks Examples
  7. Best Practices for Custom Hooks
  8. Common Pitfalls and How to Avoid Them
  9. Testing Custom Hooks
  10. Conclusion
  11. References

Introduction to Custom React Hooks

At their core, custom React hooks are JavaScript functions that follow specific conventions and leverage React’s built-in hooks to encapsulate and reuse stateful logic across multiple components. They enable you to extract component logic into reusable, modular functions, making your codebase cleaner, more maintainable, and easier to test.

By convention, custom hooks always start with the prefix use (e.g., useLocalStorage, useFetch). This naming convention is critical: it signals to React (and other developers) that the function follows the Rules of Hooks (more on this later) and can use other hooks internally.

Why Create Custom React Hooks?

Custom hooks solve a common problem in React development: repeating stateful logic across components. For example, if multiple components need to fetch data from an API, handle form validation, or sync state with localStorage, writing that logic from scratch in each component leads to duplication and bloated components.

Benefits of Custom Hooks:

  • Reusability: Share logic across components, pages, or even projects.
  • Separation of Concerns: Keep components focused on UI rendering while hooks handle logic.
  • Testability: Test hooks in isolation without rendering full components.
  • Cleaner Code: Reduce component complexity by extracting logic into dedicated hooks.

Core Principles of Custom Hooks

To create effective custom hooks, follow these fundamental principles:

1. Follow the Rules of Hooks

Custom hooks must adhere to React’s Rules of Hooks:

  • Only call hooks at the top level: Never call hooks inside loops, conditions, or nested functions.
  • Only call hooks from React functions: Call hooks from React functional components or other custom hooks (not regular JavaScript functions).

2. Naming Convention: Start with use

Always name custom hooks starting with use (e.g., useDarkMode, useScrollPosition). This ensures React can enforce the Rules of Hooks and makes your code more readable for other developers.

3. Single Responsibility Principle

Each custom hook should focus on one specific task. For example, useFetch handles data fetching, useForm manages form state, and useLocalStorage syncs state with localStorage. Avoid “kitchen sink” hooks that try to do too much—they become hard to maintain and test.

Getting Started: Setting Up Your Environment

Before diving into custom hooks, let’s set up a basic React project. We’ll use Vite for fast development (you can also use Create React App):

# Create a new React project with Vite
npm create vite@latest custom-hooks-demo -- --template react
cd custom-hooks-demo
npm install
npm run dev

Your project structure will look like this:

custom-hooks-demo/
├── src/
│   ├── hooks/        # We’ll add custom hooks here
│   ├── components/   # Components using hooks
│   ├── App.jsx
│   └── main.jsx

Create a hooks folder in src to organize your custom hooks.

Step-by-Step: Building Your First Custom Hook

Let’s start with a practical example: useLocalStorage. This hook syncs state with the browser’s localStorage, ensuring data persists across page refreshes.

Step 1: Define the Hook Logic

The useLocalStorage hook will:

  • Accept a key (for localStorage) and an initialValue.
  • Initialize state with the value from localStorage (or initialValue if none exists).
  • Update localStorage whenever the state changes.

Create src/hooks/useLocalStorage.js:

import { useState, useEffect } from 'react';

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

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

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

export default useLocalStorage;

Step 2: Use the Hook in a Component

Now, let’s use useLocalStorage in a component. For example, a theme toggler that remembers the user’s preference:

Create src/components/ThemeToggle.jsx:

import useLocalStorage from '../hooks/useLocalStorage';

function ThemeToggle() {
  // Use the custom hook: key is "theme", initial value is "light"
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <div style={{ 
      background: theme === 'light' ? '#fff' : '#333', 
      color: theme === 'light' ? '#333' : '#fff',
      padding: '2rem',
      minHeight: '100vh'
    }}>
      <h1>Current Theme: {theme}</h1>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}

export default ThemeToggle;

Import ThemeToggle into App.jsx:

import ThemeToggle from './components/ThemeToggle';

function App() {
  return (
    <div className="App">
      <ThemeToggle />
    </div>
  );
}

export default App;

Run npm run dev—toggling the theme will now persist across page refreshes!

Advanced Custom Hooks Examples

Let’s explore three powerful custom hooks for common use cases: data fetching, form handling, and media queries.

1. useFetch: Data Fetching with Loading/Error States

Fetching data is a common task—useFetch abstracts loading, error, and data states into a reusable hook.

src/hooks/useFetch.js:

import { useState, useEffect, useRef } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null); // To cancel pending requests

  useEffect(() => {
    // Create a new AbortController for each request
    abortControllerRef.current = new AbortController();
    const { signal } = abortControllerRef.current;

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, { ...options, signal });
        if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') { // Ignore abort errors on cleanup
          setError(err.message);
          setData(null);
        }
      } finally {
        if (!signal.aborted) setLoading(false); // Only update if not aborted
      }
    };

    fetchData();

    // Cleanup: Abort pending request when component unmounts or url/options change
    return () => abortControllerRef.current?.abort();
  }, [url, options]);

  return { data, loading, error };
}

export default useFetch;

Usage in a Component:

import useFetch from '../hooks/useFetch';

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

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

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

2. useForm: Form Handling with Validation

useForm simplifies form state management, including input binding and validation.

src/hooks/useForm.js:

import { useState } from 'react';

function useForm(initialValues, validateForm) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = (onSubmit) => async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    // Run validation
    const formErrors = validateForm ? validateForm(values) : {};
    setErrors(formErrors);

    // Submit only if no errors
    if (Object.keys(formErrors).length === 0 && onSubmit) {
      await onSubmit(values);
    }
    setIsSubmitting(false);
  };

  return { values, errors, isSubmitting, handleChange, handleSubmit };
}

export default useForm;

Usage in a Component:

import useForm from '../hooks/useForm';

function LoginForm() {
  const validate = (values) => {
    const errors = {};
    if (!values.email) errors.email = "Email is required";
    if (!values.password) errors.password = "Password is required";
    return errors;
  };

  const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm(
    { email: '', password: '' },
    validate
  );

  const onSubmit = async (values) => {
    console.log("Submitting:", values);
    // API call to login...
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        name="email"
        value={values.email}
        onChange={handleChange}
        placeholder="Email"
      />
      {errors.email && <p className="error">{errors.email}</p>}

      <input
        name="password"
        type="password"
        value={values.password}
        onChange={handleChange}
        placeholder="Password"
      />
      {errors.password && <p className="error">{errors.password}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Logging in..." : "Login"}
      </button>
    </form>
  );
}

3. useMediaQuery: Responsive Screen Detection

useMediaQuery checks if a CSS media query matches (e.g., “min-width: 768px”) and updates dynamically.

src/hooks/useMediaQuery.js:

import { useState, useEffect } from 'react';

function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);

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

    const handleChange = (e) => setMatches(e.matches);
    mediaQuery.addEventListener('change', handleChange);

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

  return matches;
}

export default useMediaQuery;

Usage in a Component:

import useMediaQuery from '../hooks/useMediaQuery';

function ResponsiveComponent() {
  const isMobile = useMediaQuery('(max-width: 767px)');

  return (
    <div>
      {isMobile ? (
        <p>Mobile view (≤767px)</p>
      ) : (
        <p>Desktop view (>767px)</p>
      )}
    </div>
  );
}

Best Practices for Custom Hooks

To create high-quality custom hooks, follow these best practices:

1. Document Your Hooks

Use JSDoc to explain what the hook does, its parameters, return values, and usage examples.

Example for useLocalStorage:

/**
 * Syncs state with localStorage and persists across page refreshes.
 * @param {string} key - The localStorage key to use.
 * @param {any} initialValue - The initial value if no item exists in localStorage.
 * @returns {[any, (value: any) => void]} A tuple with the current value and a setter function.
 */
function useLocalStorage(key, initialValue) { ... }

2. Compose Hooks for Complex Logic

Combine smaller hooks to build more powerful ones. For example, useFetch could use useLocalStorage to cache responses:

function useFetchWithCache(url) {
  const { data: cachedData } = useLocalStorage(`cache_${url}`);
  const { data: freshData, loading, error } = useFetch(url);

  // Update cache when fresh data loads
  useEffect(() => {
    if (freshData) useLocalStorage(`cache_${url}`, freshData);
  }, [freshData, url]);

  return { data: cachedData || freshData, loading, error };
}

3. Handle Edge Cases

Account for errors (e.g., localStorage unavailable in private browsing), default values, and cleanup (e.g., aborting fetch requests).

4. Avoid Over-Engineering

Don’t create a hook for trivial logic. For example, a hook that just wraps useState adds unnecessary complexity. Reserve hooks for reusable, non-trivial logic.

Common Pitfalls and How to Avoid Them

1. Breaking the Rules of Hooks

Problem: Calling hooks inside loops, conditionals, or nested functions.
Fix: Always call hooks at the top level of your custom hook or component.

2. Poor Naming

Problem: Not starting hooks with use (e.g., localStorageHook).
Fix: Rename to useLocalStorage to follow conventions and enable React’s linting rules.

3. Unnecessary Re-Renders

Problem: Hooks that trigger frequent updates (e.g., useFetch re-fetching on every render).
Fix: Memoize dependencies with useCallback/useMemo if needed, or ensure useEffect dependencies are stable.

4. Missing Cleanup

Problem: Leaving side effects (e.g., subscriptions, fetch requests) active after a component unmounts.
Fix: Return a cleanup function from useEffect to cancel requests or remove event listeners.

Testing Custom Hooks

Test custom hooks in isolation using @testing-library/react-hooks, which provides utilities to render hooks and assert their behavior.

Example Test for useLocalStorage:

npm install --save-dev @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear(); // Reset localStorage before each test
  });

  it('initializes with initialValue when no localStorage item exists', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
    expect(result.current[0]).toBe('initial');
  });

  it('updates localStorage when state changes', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
    
    act(() => {
      result.current[1]('updated'); // Call setter
    });

    expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated'));
    expect(result.current[0]).toBe('updated');
  });
});

Conclusion

Custom React hooks are a powerful tool for encapsulating and reusing stateful logic. By following the Rules of Hooks, focusing on single responsibility, and adhering to best practices, you can create hooks that simplify your components, improve code reusability, and enhance maintainability.

Start small—identify repetitive logic in your components, extract it into a hook, and iterate. Over time, you’ll build a library of custom hooks tailored to your project’s needs, making you a more efficient React developer.

References