React.js Fundamentals

Custom Hooks - Reusable Logic Extraction

18 min Lesson 15 of 40

Understanding Custom Hooks

Custom Hooks are JavaScript functions that let you extract component logic into reusable functions. They allow you to share stateful logic between components without changing component hierarchy or using render props and higher-order components.

A custom hook is simply a function whose name starts with "use" and that may call other Hooks. This naming convention is important - it lets React verify that your function follows the Rules of Hooks.

Benefits of Custom Hooks

Custom hooks provide code reusability, better organization, easier testing, composition of logic, and cleaner components. They help you avoid duplicating logic across multiple components.

Rules of Hooks

Before creating custom hooks, understand these fundamental rules:

  • Only call Hooks at the top level: Don't call Hooks inside loops, conditions, or nested functions
  • Only call Hooks from React functions: Call them from React function components or custom Hooks
  • Hook names must start with "use": This convention lets linters verify rules are followed
// ✅ Correct: Custom hook follows rules
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// ❌ Wrong: Not calling at top level
function useCounter() {
  if (condition) {
    const [count, setCount] = useState(0); // ❌ Hook inside condition
  }
}

Creating Your First Custom Hook

Let's create a simple custom hook for managing form input state:

import { useState } from 'react';

function useInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);

  const handleChange = (e) => {
    setValue(e.target.value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return {
    value,
    onChange: handleChange,
    reset
  };
}

// Usage in component
function LoginForm() {
  const email = useInput('');
  const password = useInput('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Email:', email.value);
    console.log('Password:', password.value);
    email.reset();
    password.reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" {...email} placeholder="Email" />
      <input type="password" {...password} placeholder="Password" />
      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;

Custom Hook Patterns

Return an object with named properties for clearer API, or return an array for positional destructuring like useState. Choose based on how users will consume your hook.

useLocalStorage Hook

A custom hook to sync state with localStorage:

import { useState, useEffect } from 'react';

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 reading from localStorage:', error);
      return initialValue;
    }
  });

  // Update localStorage when state changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error('Error writing to localStorage:', error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

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

  return (
    <div className={theme}>
      <h2>Current theme: {theme}</h2>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}

export default ThemeToggle;

useFetch Hook

A reusable hook for data fetching with loading and error states:

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 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 to prevent state updates on unmounted component
    return () => {
      isCancelled = true;
    };
  }, [url, JSON.stringify(options)]);

  return { data, loading, error };
}

// Usage
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>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

export default UserProfile;

useDebounce Hook

Delay updating a value until user stops typing:

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Set up timer to update debounced value
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Clear timeout if value changes or component unmounts
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// Usage in search component
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // Perform search with debounced value
      fetch(`https://api.example.com/search?q=${debouncedSearchTerm}`)
        .then(res => res.json())
        .then(data => setResults(data))
        .catch(err => console.error(err));
    } else {
      setResults([]);
    }
  }, [debouncedSearchTerm]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchComponent;

Avoid Overusing Custom Hooks

Not all logic needs to be extracted into custom hooks. Only create them when you have logic that is reused across multiple components or when extraction significantly improves code organization.

useToggle Hook

A simple hook for boolean toggle state:

import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue(v => !v);
  }, []);

  const setTrue = useCallback(() => {
    setValue(true);
  }, []);

  const setFalse = useCallback(() => {
    setValue(false);
  }, []);

  return [value, toggle, setTrue, setFalse];
}

// Usage
function ModalExample() {
  const [isOpen, toggleModal, openModal, closeModal] = useToggle(false);

  return (
    <div>
      <button onClick={openModal}>Open Modal</button>

      {isOpen && (
        <div className="modal">
          <h2>Modal Content</h2>
          <button onClick={closeModal}>Close</button>
        </div>
      )}
    </div>
  );
}

useWindowSize Hook

Track window dimensions for responsive behavior:

import { useState, useEffect } from 'react';

function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);

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

  return windowSize;
}

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

  return (
    <div>
      <h2>Window Size</h2>
      <p>Width: {width}px</p>
      <p>Height: {height}px</p>
      {width < 768 && <p>Mobile view</p>}
      {width >= 768 && <p>Desktop view</p>}
    </div>
  );
}

usePrevious Hook

Get the previous value of state or props:

import { useRef, useEffect } from 'react';

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// Usage
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <h2>Current: {count}</h2>
      <h3>Previous: {prevCount ?? 'None'}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Hook Composition

Custom hooks can use other custom hooks:

// Combine useLocalStorage and useDebounce
function useLocalStorageWithDebounce(key, initialValue, delay = 500) {
  const [storedValue, setStoredValue] = useLocalStorage(key, initialValue);
  const debouncedValue = useDebounce(storedValue, delay);

  useEffect(() => {
    // Sync debounced value to localStorage
    setStoredValue(debouncedValue);
  }, [debouncedValue]);

  return [storedValue, setStoredValue];
}

// Usage
function AutoSaveEditor() {
  const [content, setContent] = useLocalStorageWithDebounce(
    'editor-content',
    '',
    1000
  );

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Type something... (auto-saves to localStorage)"
      />
      <p>Content is auto-saved</p>
    </div>
  );
}

useAsync Hook

Generic hook for handling async operations:

import { useState, useCallback } from 'react';

function useAsync(asyncFunction) {
  const [status, setStatus] = useState('idle');
  const [value, setValue] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback((...params) => {
    setStatus('pending');
    setValue(null);
    setError(null);

    return asyncFunction(...params)
      .then(response => {
        setValue(response);
        setStatus('success');
        return response;
      })
      .catch(error => {
        setError(error);
        setStatus('error');
        throw error;
      });
  }, [asyncFunction]);

  return { execute, status, value, error };
}

// Usage
function UserComponent() {
  const fetchUser = async (userId) => {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return response.json();
  };

  const { execute, status, value, error } = useAsync(fetchUser);

  const loadUser = () => {
    execute(123);
  };

  return (
    <div>
      <button onClick={loadUser} disabled={status === 'pending'}>
        Load User
      </button>

      {status === 'pending' && <div>Loading...</div>}
      {status === 'success' && <div>User: {value.name}</div>}
      {status === 'error' && <div>Error: {error.message}</div>}
    </div>
  );
}

useOnClickOutside Hook

Detect clicks outside a specific element:

import { useEffect, useRef } from 'react';

function useOnClickOutside(handler) {
  const ref = useRef();

  useEffect(() => {
    const listener = (event) => {
      // Do nothing if clicking ref's element or descendent elements
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [handler]);

  return ref;
}

// Usage
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useOnClickOutside(() => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle Dropdown
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          <div>Item 1</div>
          <div>Item 2</div>
          <div>Item 3</div>
        </div>
      )}
    </div>
  );
}

Exercise 1: useForm Hook

Create a comprehensive form management hook with validation.

// Requirements:
// 1. Handle multiple form fields
// 2. Built-in validation rules (required, email, minLength, etc.)
// 3. Error state per field
// 4. Dirty/touched state
// 5. handleSubmit with validation
// 6. reset functionality

Exercise 2: useIntersectionObserver Hook

Create a hook that detects when an element enters the viewport (lazy loading).

// Requirements:
// 1. Return ref to attach to element
// 2. Return isIntersecting boolean
// 3. Accept options (threshold, rootMargin)
// 4. Cleanup observer on unmount
// 5. Use in component to lazy load images

Exercise 3: useMediaQuery Hook

Create a hook to match CSS media queries in JavaScript.

// Requirements:
// 1. Accept media query string
// 2. Return boolean match state
// 3. Update on window resize
// 4. Example queries: "(min-width: 768px)", "(prefers-color-scheme: dark)"
// 5. Cleanup listeners on unmount

Summary

  • Custom hooks extract reusable logic into functions starting with "use"
  • Follow Rules of Hooks: only call at top level and from React functions
  • Common patterns: useLocalStorage, useFetch, useDebounce, useToggle
  • Hooks can compose other hooks for complex functionality
  • Return objects for named properties or arrays for positional destructuring
  • Create custom hooks when logic is reused across components
  • Custom hooks improve code organization and testability
  • Always cleanup side effects (event listeners, timers, etc.)