javascriptroom guide

How to Create Custom Hooks in React

Hooks are functions that let you “hook into” React state and lifecycle features from functional components. Before hooks, class components were required for state management and lifecycle methods (e.g., `componentDidMount`, `componentDidUpdate`). Hooks like `useState` (for state) and `useEffect` (for side effects) eliminated this need, making functional components more powerful and concise. **Built-in React Hooks** include: - `useState`: Manages state in functional components. - `useEffect`: Handles side effects (e.g., data fetching, subscriptions). - `useContext`: Accesses context values. - `useReducer`: Manages complex state logic. - `useCallback`, `useMemo`: Optimize performance by memoizing functions/values.

React has revolutionized front-end development with its component-based architecture, and hooks—introduced in React 16.8—have further simplified state management and side-effect handling in functional components. While React provides built-in hooks like useState and useEffect, custom hooks empower you to extract and reuse component logic across multiple components. In this guide, we’ll dive deep into creating custom hooks, from the basics to advanced patterns, with practical examples and best practices.

Table of Contents

  1. Introduction to React Hooks
  2. Rules of Hooks
  3. Why Create Custom Hooks?
  4. How to Create a Custom Hook: Step-by-Step
  5. Example 2: Form Handling with useForm
  6. Advanced Custom Hook Patterns
  7. Best Practices for Custom Hooks
  8. Common Pitfalls to Avoid
  9. Conclusion
  10. References

Rules of Hooks

Custom hooks are built on top of React’s built-in hooks, so they must follow the same rules:

  1. Only call hooks at the top level: Don’t call hooks inside loops, conditions, or nested functions. This ensures hooks are called in the same order every time a component renders.
  2. Only call hooks from React functions: Call hooks in functional components or other custom hooks (never in regular JavaScript functions).

Why Create Custom Hooks?

Custom hooks solve a critical problem: reusing stateful logic between components. Without custom hooks, you might duplicate logic across components (e.g., data fetching, form handling) or use complex patterns like render props or higher-order components (HOCs). Custom hooks:

  • Promote code reuse: Extract logic into a single function and reuse it across components.
  • Improve readability: Simplify component code by moving complex logic to a dedicated hook.
  • Encapsulate logic: Hide implementation details, making components focused on UI.

How to Create a Custom Hook: Step-by-Step

Creating a custom hook is straightforward:

  1. Name the function starting with use: This convention tells React (and other developers) that the function is a hook and follows hook rules.
  2. Extract reusable logic: Move stateful logic (e.g., useState, useEffect) from a component into the custom hook.
  3. Return values the component needs: The hook can return state variables, functions, or other values that the component will use.

Example 1: Data Fetching with useFetch

Let’s build a useFetch hook to handle data fetching logic. This is a common use case, as many components need to fetch data from APIs.

Problem: Duplicate Fetch Logic

Without a custom hook, you might repeat this code in every component that fetches data:

// UserComponent.jsx
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
        setError(null);
      } catch (err) {
        setError(err.message);
        setUser(null);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Re-run effect when userId changes

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

This works, but if another component (e.g., PostComponent) needs similar fetch logic, you’d duplicate most of this code.

Solution: Extract Logic into useFetch

Let’s create useFetch to encapsulate the fetch logic:

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

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

  useEffect(() => {
    // Abort controller to cancel pending requests
    const abortController = new AbortController();

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

    fetchData();

    // Cleanup: abort request if component unmounts or url changes
    return () => abortController.abort();
  }, [url]); // Re-run effect when url changes

  return { data, loading, error }; // Return values for components
}

export default useFetch;

Using useFetch in Components

Now, any component can reuse this logic with just a few lines:

// UserComponent.jsx
import useFetch from './useFetch';

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

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

// PostComponent.jsx
import useFetch from './useFetch';

function PostComponent({ postId }) {
  const { data: post, loading, error } = useFetch(`https://api.example.com/posts/${postId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>Post Title: {post.title}</div>;
}

Key Improvements:

  • No duplicated fetch logic.
  • Built-in cleanup with AbortController to prevent memory leaks.
  • Consistent error/loading handling across components.

Example 2: Form Handling with useForm

Another common use case is form state management. Let’s build a useForm hook to simplify form handling.

Step 1: Create useForm

// useForm.js
import { useState } from 'react';

function useForm(initialValues) {
  // Initialize form state with initial values
  const [values, setValues] = useState(initialValues);

  // Update state when input values change
  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues((prevValues) => ({
      ...prevValues,
      [name]: value, // Dynamic key based on input name
    }));
  };

  // Reset form to initial values
  const resetForm = () => {
    setValues(initialValues);
  };

  return { values, handleChange, resetForm }; // Return state and helpers
}

export default useForm;

Step 2: Use useForm in a Component

// LoginForm.jsx
import useForm from './useForm';

function LoginForm() {
  // Initialize form with default values
  const { values, handleChange, resetForm } = useForm({
    email: '',
    password: '',
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Form submitted:', values);
    // Reset form after submission
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email" // Must match state key
          value={values.email}
          onChange={handleChange}
          required
        />
      </div>
      <div>
        <label>Password:</label>
        <input
          type="password"
          name="password" // Must match state key
          value={values.password}
          onChange={handleChange}
          required
        />
      </div>
      <button type="submit">Login</button>
      <button type="button" onClick={resetForm}>Reset</button>
    </form>
  );
}

export default LoginForm;

Why This Works:

  • useForm abstracts state updates and reset logic.
  • handleChange dynamically updates the state based on the input’s name attribute, eliminating the need for separate handlers for each field.

Advanced Custom Hook Patterns

Custom hooks can handle more complex scenarios, such as:

Custom Hooks with Parameters

Hooks can accept parameters to make them flexible. For example, extend useFetch to accept custom headers:

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

  useEffect(() => {
    const abortController = new AbortController();
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, {
          ...options,
          signal: abortController.signal,
        });
        // ... rest of logic ...
      } catch (err) { /* ... */ }
    };
    fetchData();
    return () => abortController.abort();
  }, [url, options]); // Include options in dependencies

  return { data, loading, error };
}

// Usage: Pass headers for authenticated requests
const { data } = useFetch('/api/protected', {
  headers: { Authorization: `Bearer ${token}` }
});

Composing Custom Hooks

Hooks can call other hooks to build more complex logic. For example, a useLocalStorage hook to persist state, and a useUserPreferences hook that uses it:

// useLocalStorage.js: Persist state to localStorage
function useLocalStorage(key, initialValue) {
  // Initialize state with localStorage value (or initialValue)
  const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialValue;
  });

  // Update localStorage when value changes
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// useUserPreferences.js: Compose useLocalStorage
function useUserPreferences() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [notifications, setNotifications] = useLocalStorage('notifications', true);

  return { theme, setTheme, notifications, setNotifications };
}

// Usage in a component
function Settings() {
  const { theme, setTheme } = useUserPreferences();
  return (
    <div>
      <p>Theme: {theme}</p>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  );
}

Hooks with Cleanup Logic

Hooks often need to clean up side effects (e.g., subscriptions, timers). Use the cleanup function returned by useEffect for this:

// useTimer.js: Track elapsed time
function useTimer(initialSeconds = 0) {
  const [seconds, setSeconds] = useState(initialSeconds);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    // Cleanup: Clear interval when component unmounts
    return () => clearInterval(intervalId);
  }, []); // Empty dependency array: run once on mount

  return seconds;
}

// Usage
function TimerDisplay() {
  const seconds = useTimer();
  return <div>Elapsed: {seconds}s</div>;
}

Best Practices for Custom Hooks

  1. Follow the use naming convention: Start hook names with use (e.g., useFetch, useForm). This makes hooks easy to identify and ensures linter support.
  2. Single responsibility principle: Each hook should handle one type of logic (e.g., useFetch for data fetching, useForm for forms). Avoid “god hooks” that do too much.
  3. Document the hook: Add JSDoc comments to explain what the hook does, its parameters, and return values. For example:
    /**
     * Custom hook to fetch data from a URL.
     * @param {string} url - The API endpoint to fetch.
     * @returns {Object} { data, loading, error }
     */
    function useFetch(url) { /* ... */ }
  4. Handle edge cases: Include error handling, loading states, and cleanup (e.g., AbortController in useFetch).
  5. Test hooks in isolation: Use tools like @testing-library/react-hooks to test hooks independently of components.

Common Pitfalls to Avoid

  • Violating hook rules: Never call hooks inside loops, conditions, or nested functions. This breaks React’s internal hook ordering.
  • Overcomplicating hooks: Don’t create a custom hook for trivial logic (e.g., a single useState call). Reserve hooks for reusable, non-trivial logic.
  • Missing dependencies in useEffect: Always include all variables used inside useEffect in the dependency array (use the React linter to catch this).
  • Ignoring cleanup: Forgetting to clean up side effects (e.g., intervals, subscriptions) can cause memory leaks or stale state.

Conclusion

Custom hooks are a powerful way to reuse stateful logic in React. By encapsulating logic into reusable functions, you can write cleaner, more maintainable code and avoid duplication. Start with simple hooks (like useFetch or useForm), then experiment with advanced patterns like composition and parameterized hooks.

Remember: The best custom hooks solve a specific problem and follow the rules of hooks. With practice, you’ll find endless ways to simplify your React components!

References