Custom Hooks Development in React

Module 25: Frontend Frameworks & State Management

Understanding React Hooks

Hooks are functions that let you "hook into" React state and lifecycle features from function components. They were introduced in React 16.8 to allow developers to use state and other React features without writing class components.

Think of hooks as special tools in your toolbox that each solve a specific problem. Just as a carpenter has specialized tools for different tasks, React offers built-in hooks for common needs:

graph LR A[React Component] --> B[useState] A --> C[useEffect] A --> D[useContext] A --> E[useReducer] A --> F[useRef] A --> G[useMemo] A --> H[useCallback] A --> I[Custom Hooks] I --> J[Reusable Logic]

What Are Custom Hooks?

Custom hooks are JavaScript functions that start with "use" and may call other hooks. They allow you to extract component logic into reusable functions, promoting code reuse and separation of concerns.

Real-world analogy: If built-in hooks are like the basic tools that come with your toolbox, custom hooks are like specialized tools you craft yourself for specific, repeated tasks. For instance, while a standard screwdriver (built-in hook) works for many jobs, you might create a specialized screwdriver attachment (custom hook) for a particular type of screw you encounter regularly.

Benefits of Custom Hooks

Rules of Hooks

Before creating custom hooks, remember these essential rules:

Violating these rules can lead to bugs that are difficult to track down, as they break React's internal hook mechanism.

Creating Your First Custom Hook

Let's create a simple custom hook for toggling boolean state, a common pattern in React applications:

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

function useToggle(initialState = false) {
  const [state, setState] = useState(initialState);
  
  const toggle = () => {
    setState(prevState => !prevState);
  };
  
  return [state, toggle];
}

export default useToggle;

// Usage in a component
import useToggle from './useToggle';

function ToggleComponent() {
  const [isOn, toggle] = useToggle();
  
  return (
    <div>
      <p>The switch is {isOn ? 'ON' : 'OFF'}</p>
      <button onClick={toggle}>Toggle</button>
    </div>
  );
}

This simple hook saves us from repeatedly writing the same toggle logic across components. It's a small example but demonstrates the core concept of custom hooks.

Practical Custom Hook: useLocalStorage

Let's create a more useful custom hook that syncs state with localStorage:

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

function useLocalStorage(key, initialValue) {
  // Get stored value from localStorage or use initialValue
  const getStoredValue = () => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  };

  // State to store our value
  const [value, setValue] = useState(getStoredValue);

  // Update localStorage when state changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  }, [key, value]);

  return [value, setValue];
}

export default useLocalStorage;

// Usage in a component
import useLocalStorage from './useLocalStorage';

function SettingsComponent() {
  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>
  );
}

This hook handles all the localStorage logic, error handling, and syncing. You can use it just like useState, but your state persists in localStorage between page reloads.

Real-world applications: Theme preferences, user settings, form data persistence, shopping cart contents in e-commerce sites.

Data Fetching Custom Hook

Data fetching is one of the most common use cases for custom hooks. Let's create a useFetch hook:

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

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let isMounted = true;
    
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, options);
        
        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const result = await response.json();
        
        if (isMounted) {
          setData(result);
          setError(null);
        }
      } catch (error) {
        if (isMounted) {
          setError(error.message);
          setData(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    // Cleanup function to prevent state updates on unmounted components
    return () => {
      isMounted = false;
    };
  }, [url, JSON.stringify(options)]);
  
  return { data, loading, error };
}

export default useFetch;

// Usage in a component
import useFetch from './useFetch';

function UserProfile({ userId }) {
  const { data, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  
  return (
    <div>
      <h2>{data.name}</h2>
      <p>Email: {data.email}</p>
      {/* More user details */}
    </div>
  );
}

This hook encapsulates the entire data fetching process, including loading states, error handling, and cleanup. Now components can focus on displaying data rather than managing fetch logic.

Note how we use isMounted to prevent state updates after the component unmounts, avoiding memory leaks and React warnings.

Composing Custom Hooks

One of the powerful features of hooks is composability. You can create hooks that use other hooks, building complex behavior from simpler pieces.

Let's create a usePaginatedFetch hook that builds on our useFetch hook:

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

function usePaginatedFetch(baseUrl, itemsPerPage = 10) {
  const [page, setPage] = useState(1);
  
  const url = `${baseUrl}?page=${page}&limit=${itemsPerPage}`;
  const { data, loading, error } = useFetch(url);
  
  const nextPage = () => {
    setPage(prevPage => prevPage + 1);
  };
  
  const prevPage = () => {
    setPage(prevPage => Math.max(prevPage - 1, 1));
  };
  
  return {
    data,
    loading,
    error,
    page,
    nextPage,
    prevPage,
    setPage
  };
}

export default usePaginatedFetch;

// Usage in a component
import usePaginatedFetch from './usePaginatedFetch';

function UserList() {
  const {
    data,
    loading,
    error,
    page,
    nextPage,
    prevPage
  } = usePaginatedFetch('https://api.example.com/users', 20);
  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  
  return (
    <div>
      <h2>User List (Page {page})</h2>
      <ul>
        {data.items.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      
      <div>
        <button onClick={prevPage} disabled={page === 1}>
          Previous Page
        </button>
        <button onClick={nextPage} disabled={!data.hasMore}>
          Next Page
        </button>
      </div>
    </div>
  );
}

By composing hooks, we've built a pagination system on top of our data fetching logic. This demonstrates how custom hooks can be layered to create increasingly sophisticated behavior.

Practice Activity

Create a useForm Hook

Implement a custom useForm hook that simplifies form handling in React:

  1. Create a hook that manages form values, validation, and submission
  2. Support input change handling automatically
  3. Add basic validation capabilities
  4. Provide a submit handler

Start with this skeleton:

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

function useForm(initialValues = {}, validate = () => ({})) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  // TODO: Implement handleChange
  
  // TODO: Implement handleBlur
  
  // TODO: Implement handleSubmit
  
  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    isSubmitting
  };
}

// Example usage
function SignupForm() {
  const { 
    values, 
    errors, 
    touched, 
    handleChange, 
    handleBlur, 
    handleSubmit 
  } = useForm({
    email: '',
    password: ''
  }, values => {
    const errors = {};
    if (!values.email) errors.email = 'Email is required';
    if (!values.password) errors.password = 'Password is required';
    return errors;
  });
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  );
}

Key Takeaways

Additional Resources