You are viewing a preview of this lesson. Sign in to start learning
Back to React

Custom Hooks

Create reusable custom hooks for shared logic patterns

Custom Hooks in React

Master the art of building reusable logic with custom React hooks through free flashcards and spaced repetition practice. This lesson covers hook creation patterns, state management encapsulation, and best practices for composing custom hooksβ€”essential skills for writing clean, maintainable React applications.

Welcome to Custom Hooks 🎣

Custom hooks are one of React's most powerful features, allowing you to extract component logic into reusable functions. They're the secret weapon for eliminating code duplication, organizing complex logic, and creating a library of shareable utilities that work seamlessly across your entire application.

Think of custom hooks as your personal toolbox 🧰. Just as a carpenter creates specialized tools for repeated tasks, you'll create custom hooks for common patterns in your React applications. Once you master this skill, you'll write less code, maintain cleaner components, and ship features faster.

Core Concepts: Understanding Custom Hooks πŸ’‘

What Makes a Hook "Custom"?

A custom hook is simply a JavaScript function whose name starts with "use" and that may call other hooks. That's it! The "use" prefix is a convention that tells React (and developers) that this function follows the Rules of Hooks.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         HOOK HIERARCHY                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  Built-in Hooks (useState, useEffect)   β”‚
β”‚           ↓                             β”‚
β”‚  Custom Hooks (useForm, useFetch)       β”‚
β”‚           ↓                             β”‚
β”‚  Components (LoginForm, UserProfile)    β”‚
β”‚                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Here's the simplest possible custom hook:

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

This hook encapsulates the logic for setting the browser's document title. Any component can now use it:

function ProfilePage() {
  useDocumentTitle('User Profile');
  return <div>Profile content...</div>;
}

Why Create Custom Hooks? 🎯

1. Code Reusability: Instead of copying the same logic across multiple components, write it once in a custom hook.

2. Separation of Concerns: Keep your components focused on rendering UI while hooks handle complex logic.

3. Testability: Custom hooks can be tested independently from components.

4. Composition: Hooks can call other hooks, allowing you to build complex behavior from simple pieces.

The Anatomy of a Custom Hook πŸ”¬

Let's examine a real-world custom hook that manages form state:

function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  
  const handleChange = (e) => {
    setValue(e.target.value);
  };
  
  const reset = () => {
    setValue(initialValue);
  };
  
  return {
    value,
    onChange: handleChange,
    reset
  };
}

This hook:

  • βœ… Starts with "use"
  • βœ… Uses other hooks (useState)
  • βœ… Returns values that components can use
  • βœ… Encapsulates related logic (state + handlers)

Rules of Custom Hooks ⚠️

Custom hooks must follow the same rules as built-in hooks:

RuleWhy It Matters
Only call at top levelReact relies on hook call order to track state
Only call from React functionsHooks need React's rendering context
Name starts with "use"Enables linting and signals hook behavior

❌ Wrong:

function MyComponent() {
  if (condition) {
    const data = useFetch(url); // Conditional hook call!
  }
}

βœ… Right:

function MyComponent() {
  const data = useFetch(condition ? url : null); // Hook always called
}

Example 1: useToggle - Managing Boolean State πŸ”„

One of the most common patterns is toggling boolean state (modals, menus, dark mode, etc.). Let's create a reusable hook:

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = useCallback(() => {
    setValue(prev => !prev);
  }, []);
  
  const setTrue = useCallback(() => {
    setValue(true);
  }, []);
  
  const setFalse = useCallback(() => {
    setValue(false);
  }, []);
  
  return [value, { toggle, setTrue, setFalse }];
}

Key features:

  • Returns array like useState (destructuring syntax)
  • Includes helper functions beyond just setState
  • Uses useCallback to memoize functions

Usage in components:

function Modal() {
  const [isOpen, { toggle, setTrue, setFalse }] = useToggle();
  
  return (
    <>
      <button onClick={setTrue}>Open Modal</button>
      {isOpen && (
        <div className="modal">
          <h2>Modal Content</h2>
          <button onClick={setFalse}>Close</button>
        </div>
      )}
    </>
  );
}

function Sidebar() {
  const [isExpanded, { toggle }] = useToggle(true);
  
  return (
    <aside className={isExpanded ? 'expanded' : 'collapsed'}>
      <button onClick={toggle}>Toggle Sidebar</button>
      {/* sidebar content */}
    </aside>
  );
}

πŸ’‘ Why this works: The same toggle logic is now reusable across any component. Notice how each component gets its own independent stateβ€”React creates a new instance of the hook for each component that uses it.

Example 2: useFetch - Handling Async Data 🌐

Fetching data is perhaps the most common side effect in React apps. Let's build a robust custom hook:

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Handle empty URL
    if (!url) {
      setLoading(false);
      return;
    }
    
    let isCancelled = false;
    
    const fetchData = async () => {
      setLoading(true);
      setError(null);
      
      try {
        const response = await fetch(url, options);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const json = await response.json();
        
        if (!isCancelled) {
          setData(json);
          setLoading(false);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    // Cleanup function
    return () => {
      isCancelled = true;
    };
  }, [url, JSON.stringify(options)]);
  
  return { data, loading, error };
}

What's happening here:

  1. Three pieces of state: data, loading, and error
  2. Effect dependency: Re-fetches when URL or options change
  3. Cleanup mechanism: Prevents state updates after unmount
  4. Error handling: Catches network and HTTP errors

Using the hook:

function UserProfile({ 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>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

πŸ”§ Try this: Add a refetch function to the return value that manually triggers a new fetch:

const refetch = useCallback(() => {
  setLoading(true);
  // trigger fetch again
}, [url]);

return { data, loading, error, refetch };

Example 3: useLocalStorage - Persisting State πŸ’Ύ

Synchronizing state with localStorage is another common pattern:

function useLocalStorage(key, initialValue) {
  // Get initial value from localStorage or use initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });
  
  // Return wrapped version of useState's setter
  const setValue = useCallback((value) => {
    try {
      // Allow value to be a function like useState
      const valueToStore = 
        value instanceof Function ? value(storedValue) : value;
      
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);
  
  // Remove item from localStorage
  const removeValue = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error(error);
    }
  }, [key, initialValue]);
  
  return [storedValue, setValue, removeValue];
}

Key techniques:

  • Lazy initialization: useState accepts a function to compute initial state
  • Try-catch blocks: Handles quota exceeded and parsing errors
  • Function updaters: Supports setValue(prev => prev + 1) syntax
  • Multiple return values: Provides remove functionality

Practical usage:

function ThemeToggle() {
  const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
  
  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };
  
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}

function ShoppingCart() {
  const [cart, setCart] = useLocalStorage('cart', []);
  
  const addItem = (item) => {
    setCart(prev => [...prev, item]);
  };
  
  const clearCart = () => {
    setCart([]);
  };
  
  return (
    <div>
      <div>Items in cart: {cart.length}</div>
      {/* cart UI */}
    </div>
  );
}

🧠 Memory device: Think of useLocalStorage as "useState with a backup disk." Every state change gets automatically saved and restored.

Example 4: useDebounce - Optimizing Performance ⚑

Debouncing is crucial for search inputs, resize handlers, and any high-frequency events:

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    // Set up the timeout
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    // Cleanup: cancel timeout if value changes before delay
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

How it works:

USER TYPES: "r" β†’ "re" β†’ "rea" β†’ "reac" β†’ "react"
            ↓     ↓      ↓       ↓        ↓
TIMEOUTS:   500ms 500ms  500ms   500ms    500ms
            (cancelledΓ—4)              βœ“ fires
                                          ↓
API CALL:                            "react"

Only the final value triggers the API call, saving unnecessary requests!

Real-world application:

function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
  
  const { data: users, loading } = useFetch(
    debouncedSearchTerm 
      ? `https://api.example.com/users?search=${debouncedSearchTerm}`
      : null
  );
  
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search users..."
      />
      {loading && <div>Searching...</div>}
      {users && (
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

πŸ’‘ Pro tip: You can also create useThrottle with a similar pattern, but it fires at regular intervals instead of waiting for silence.

Composing Custom Hooks πŸ”—

The real power emerges when you combine custom hooks. Here's a hook that uses multiple other hooks:

function useApiData(url) {
  const { data, loading, error, refetch } = useFetch(url);
  const [isStale, setIsStale] = useState(false);
  
  // Mark data as stale after 5 minutes
  useEffect(() => {
    if (!data) return;
    
    const timer = setTimeout(() => {
      setIsStale(true);
    }, 5 * 60 * 1000);
    
    return () => clearTimeout(timer);
  }, [data]);
  
  // Auto-refresh if stale and window gains focus
  useEffect(() => {
    const handleFocus = () => {
      if (isStale) {
        refetch();
        setIsStale(false);
      }
    };
    
    window.addEventListener('focus', handleFocus);
    return () => window.removeEventListener('focus', handleFocus);
  }, [isStale, refetch]);
  
  return { data, loading, error, isStale, refetch };
}

This hook composes:

  • useFetch for data fetching
  • useState for staleness tracking
  • useEffect for timers and event listeners

Common Mistakes ⚠️

1. Breaking the Rules of Hooks

❌ Calling hooks conditionally:

function useBadExample(shouldFetch) {
  if (shouldFetch) {
    const data = useFetch(url); // WRONG!
  }
}

βœ… Pass conditions to the hook:

function useGoodExample(shouldFetch) {
  const data = useFetch(shouldFetch ? url : null); // RIGHT!
}

2. Forgetting Dependencies

❌ Missing dependencies:

function useBadEffect(id) {
  useEffect(() => {
    fetchData(id); // Uses 'id' but not in deps!
  }, []); // WRONG!
}

βœ… Include all dependencies:

function useGoodEffect(id) {
  useEffect(() => {
    fetchData(id);
  }, [id]); // RIGHT!
}

3. Not Cleaning Up Effects

❌ Memory leaks:

function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    // No cleanup! WRONG!
  }, [callback, delay]);
}

βœ… Always clean up:

function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay);
    return () => clearInterval(id); // RIGHT!
  }, [callback, delay]);
}

4. Overcomplicating Return Values

❌ Confusing API:

function useBadApi() {
  return {
    theDataValue: data,
    isCurrentlyLoading: loading,
    theErrorIfAny: error
  };
}

βœ… Keep it simple:

function useGoodApi() {
  return { data, loading, error };
}

5. Recreating Functions on Every Render

❌ New function every render:

function useForm() {
  const [value, setValue] = useState('');
  
  return {
    value,
    onChange: (e) => setValue(e.target.value) // New function each time!
  };
}

βœ… Memoize with useCallback:

function useForm() {
  const [value, setValue] = useState('');
  
  const onChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);
  
  return { value, onChange };
}

Advanced Patterns πŸš€

Hook Factories

Create hooks that generate other hooks:

function createUseStorage(storageType) {
  return function useStorage(key, initialValue) {
    const storage = window[storageType];
    
    const [value, setValue] = useState(() => {
      try {
        const item = storage.getItem(key);
        return item ? JSON.parse(item) : initialValue;
      } catch {
        return initialValue;
      }
    });
    
    const updateValue = useCallback((newValue) => {
      setValue(newValue);
      storage.setItem(key, JSON.stringify(newValue));
    }, [key]);
    
    return [value, updateValue];
  };
}

// Now create specialized hooks
const useLocalStorage = createUseStorage('localStorage');
const useSessionStorage = createUseStorage('sessionStorage');

Hooks with TypeScript πŸ“˜

Custom hooks benefit greatly from TypeScript:

function useFetch<T>(url: string | null): {
  data: T | null;
  loading: boolean;
  error: string | null;
} {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  
  // ... implementation
  
  return { data, loading, error };
}

// Usage with type inference
interface User {
  id: number;
  name: string;
}

const { data: user } = useFetch<User>('/api/user');
// 'user' is typed as User | null

Key Takeaways 🎯

πŸ“‹ Custom Hooks Quick Reference

What they areReusable functions that use React hooks
Naming conventionMust start with "use"
Key benefitExtract and share component logic
Can callOther hooks (built-in or custom)
Can returnAny value (state, functions, objects, arrays)
Must followRules of Hooks (top-level, React functions only)
Best forData fetching, subscriptions, timers, form handling
TestingUse @testing-library/react-hooks

Remember these principles:

  1. 🎯 Single Responsibility: Each hook should do one thing well
  2. πŸ”„ Composition over Complexity: Build complex hooks from simple ones
  3. πŸ“ Clear APIs: Return values should be intuitive and well-documented
  4. 🧹 Clean Up: Always return cleanup functions from effects
  5. ⚑ Performance: Memoize callbacks and values when needed
  6. πŸ§ͺ Testability: Design hooks to be testable independently

πŸ“š Further Study

  1. React Official Docs - Building Your Own Hooks: https://react.dev/learn/reusing-logic-with-custom-hooks
  2. useHooks - Collection of React Hook Recipes: https://usehooks.com/
  3. React Hooks Testing Library: https://react-hooks-testing-library.com/

πŸŽ“ You now have the foundation to create custom hooks! Start by identifying repeated logic in your components, extract it into a hook, and watch your codebase become cleaner and more maintainable. The best way to learn is by buildingβ€”try creating hooks for your own common patterns.