Intermediate60 min read

Building Custom Hooks

Learn how to extract component logic into reusable custom hooks to share stateful logic between components.

Topics Covered:

Custom HooksHook RulesReusable LogicHook CompositionTesting HooksHook Patterns

Prerequisites:

  • Managing State with useState
  • Understanding useEffect
  • Event Handling in React

Video Tutorial

Overview

Custom hooks are JavaScript functions that start with 'use' and can call other hooks. They allow you to extract component logic into reusable functions. Custom hooks are one of React's most powerful features for code reuse and organization. This tutorial covers how to create custom hooks, follow the rules of hooks, and build reusable logic that can be shared across components.

What are Custom Hooks

Custom hooks are functions that encapsulate reusable logic using React hooks. They follow a simple naming convention and can use any React hooks inside them. Key Characteristics: • Functions that start with 'use' • Can call other hooks • Share stateful logic between components • Don't render anything themselves • Follow the same rules as regular hooks Why Use Custom Hooks: • Reuse logic across components • Separate concerns • Make components cleaner • Test logic independently • Share logic with the community Benefits: • DRY (Don't Repeat Yourself) • Better organization • Easier testing • Reusable across projects

Code Example:
// Simple custom hook
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 };
}

// Using the custom hook
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

// Multiple components can use the same hook
function AnotherCounter() {
  const { count, increment } = useCounter(10);
  return <button onClick={increment}>{count}</button>;
}

// Custom hook with parameters
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = () => setValue(v => !v);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);
  
  return { value, toggle, setTrue, setFalse };
}

// Usage
function ToggleButton() {
  const { value, toggle } = useToggle();
  
  return (
    <button onClick={toggle}>
      {value ? 'ON' : 'OFF'}
    </button>
  );
}

Custom hooks are functions starting with 'use' that can use React hooks. They return values and functions that components can use. This allows sharing logic between components.

Rules of Hooks

Custom hooks must follow the same rules as regular hooks. Understanding these rules is crucial for writing correct custom hooks. Rules of Hooks: 1. Only call hooks at the top level • Don't call inside loops, conditions, or nested functions • Always call in the same order 2. Only call hooks from React functions • React function components • Custom hooks (functions starting with 'use') • Not from regular JavaScript functions Why These Rules Exist: • React relies on call order to track hooks • Violating rules causes bugs • ESLint plugin helps catch violations Common Mistakes: • Calling hooks conditionally • Calling hooks in loops • Calling hooks in regular functions • Calling hooks in event handlers

Code Example:
// ❌ BAD: Conditional hook call
function BadComponent({ condition }) {
  if (condition) {
    const [value, setValue] = useState(0); // ❌ Wrong!
  }
  return <div>Content</div>;
}

// ✅ GOOD: Always call hooks
function GoodComponent({ condition }) {
  const [value, setValue] = useState(0); // ✅ Always called
  return <div>Content</div>;
}

// ❌ BAD: Hook in loop
function BadList({ items }) {
  const states = [];
  items.forEach(item => {
    states.push(useState(item)); // ❌ Wrong!
  });
  return <div>List</div>;
}

// ✅ GOOD: Extract to component
function GoodList({ items }) {
  return (
    <div>
      {items.map(item => (
        <ListItem key={item.id} item={item} />
      ))}
    </div>
  );
}

function ListItem({ item }) {
  const [value, setValue] = useState(item); // ✅ OK in component
  return <div>{value}</div>;
}

// ❌ BAD: Hook in regular function
function regularFunction() {
  const [value, setValue] = useState(0); // ❌ Wrong!
}

// ✅ GOOD: Custom hook
function useCustomHook() {
  const [value, setValue] = useState(0); // ✅ OK in custom hook
  return value;
}

// ✅ GOOD: Conditional logic inside hook
function useConditional(condition) {
  const [value, setValue] = useState(0);
  
  useEffect(() => {
    if (condition) {
      // ✅ OK: conditional logic inside hook
      setValue(1);
    }
  }, [condition]);
  
  return value;
}

Hooks must be called at the top level, in the same order every render. Don't call hooks conditionally, in loops, or in regular functions. Use conditional logic inside hooks, not around hook calls.

Common Custom Hook Patterns

There are many common patterns for custom hooks. Learning these patterns helps you build reusable hooks effectively. Common Patterns: • State management hooks (useToggle, useCounter) • Data fetching hooks (useFetch, useAPI) • DOM hooks (useWindowSize, useScrollPosition) • Form hooks (useForm, useInput) • Timer hooks (useInterval, useTimeout) • Storage hooks (useLocalStorage, useSessionStorage) • Media hooks (useMediaQuery, useAudio) Pattern Structure: • Encapsulate related logic • Return values and functions • Handle cleanup • Provide sensible defaults

Code Example:
// Data fetching hook
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user.name}</div>;
}

// Local storage hook
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });
  
  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  
  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

// Window size hook
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return size;
}

// Usage
function ResponsiveComponent() {
  const { width } = useWindowSize();
  return <div>{width < 768 ? 'Mobile' : 'Desktop'}</div>;
}

// Interval hook
function useInterval(callback, delay) {
  const savedCallback = useRef();
  
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
  
  useEffect(() => {
    if (delay === null) return;
    
    const id = setInterval(() => {
      savedCallback.current();
    }, delay);
    
    return () => clearInterval(id);
  }, [delay]);
}

// Usage
function Timer() {
  const [count, setCount] = useState(0);
  
  useInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  
  return <div>Count: {count}</div>;
}

Common custom hook patterns include data fetching, local storage, window size, intervals, and more. These patterns encapsulate reusable logic that multiple components can use.

Composing Custom Hooks

Custom hooks can call other custom hooks, allowing you to build complex functionality by composing simpler hooks. Composition Benefits: • Build complex hooks from simple ones • Reuse existing hooks • Create powerful abstractions • Keep hooks focused and testable Composition Patterns: • Chain hooks together • Combine multiple hooks • Create hook hierarchies • Share state between hooks

Code Example:
// Simple hooks
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  return { count, increment, decrement };
}

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(v => !v);
  return { value, toggle };
}

// Composed hook
function useCounterWithToggle(initialCount = 0) {
  const counter = useCounter(initialCount);
  const toggle = useToggle();
  
  const reset = () => {
    counter.setCount(initialCount);
    toggle.setFalse();
  };
  
  return {
    ...counter,
    isActive: toggle.value,
    toggleActive: toggle.toggle,
    reset
  };
}

// Complex composition
function useUserProfile(userId) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
  const [preferences, setPreferences] = useLocalStorage(`user-${userId}-prefs`, {});
  const { width } = useWindowSize();
  
  const updatePreferences = (newPrefs) => {
    setPreferences({ ...preferences, ...newPrefs });
  };
  
  const isMobile = width < 768;
  
  return {
    user,
    loading,
    error,
    preferences,
    updatePreferences,
    isMobile
  };
}

// Usage
function ProfilePage({ userId }) {
  const {
    user,
    loading,
    preferences,
    updatePreferences,
    isMobile
  } = useUserProfile(userId);
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <div className={isMobile ? 'mobile' : 'desktop'}>
      <h1>{user.name}</h1>
      {/* ... */}
    </div>
  );
}

// Hook that uses other hooks
function usePaginatedData(url, pageSize = 10) {
  const [page, setPage] = useState(1);
  const { data, loading, error } = useFetch(`${url}?page=${page}&limit=${pageSize}`);
  
  const nextPage = () => setPage(p => p + 1);
  const prevPage = () => setPage(p => Math.max(1, p - 1));
  
  return {
    data,
    loading,
    error,
    page,
    nextPage,
    prevPage,
    hasNext: data?.hasMore || false
  };
}

Compose custom hooks by calling other hooks inside them. This allows building complex functionality from simpler pieces. Keep hooks focused and compose them for more complex use cases.

Testing Custom Hooks

Testing custom hooks requires special tools since hooks can only be called from React components. React Testing Library provides utilities for testing hooks. Testing Approaches: • Use @testing-library/react-hooks • Render hook in test component • Test return values • Test side effects • Test cleanup What to Test: • Return values • State updates • Side effects • Cleanup functions • Edge cases • Error handling

Code Example:
// Custom hook to test
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  return { count, increment, decrement };
}

// Test using renderHook
import { renderHook, act } from '@testing-library/react-hooks';

describe('useCounter', () => {
  it('should initialize with value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });
  
  it('should increment count', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  it('should decrement count', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
});

// Testing hook with dependencies
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData).finally(() => setLoading(false));
  }, [url]);
  
  return { data, loading };
}

// Mock fetch for testing
global.fetch = jest.fn();

describe('useFetch', () => {
  it('should fetch data', async () => {
    const mockData = { id: 1, name: 'Test' };
    fetch.mockResolvedValueOnce({
      json: async () => mockData
    });
    
    const { result, waitForNextUpdate } = renderHook(() => 
      useFetch('/api/test')
    );
    
    expect(result.current.loading).toBe(true);
    
    await waitForNextUpdate();
    
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toEqual(mockData);
  });
});

Test custom hooks using renderHook from React Testing Library. Use act() for state updates, mock external dependencies, and test all return values and side effects.

Best Practices for Custom Hooks

Following best practices makes your custom hooks more maintainable, reusable, and easier to understand. Best Practices: • Start hook names with 'use' • Keep hooks focused on single responsibility • Return objects for multiple values, arrays for pairs • Handle cleanup properly • Provide sensible defaults • Document hook behavior • Handle edge cases • Make hooks composable Common Patterns: • Return object for flexibility • Return array for destructuring (like useState) • Use useCallback/useMemo when needed • Handle loading and error states • Provide reset/clear functions

Code Example:
// ✅ GOOD: Focused, single responsibility
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

// ✅ GOOD: Return object for flexibility
function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  
  const setValue = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
  };
  
  const setError = (name, error) => {
    setErrors(prev => ({ ...prev, [name]: error }));
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
  };
  
  return {
    values,
    errors,
    setValue,
    setError,
    reset
  };
}

// ✅ GOOD: Handle edge cases
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === 'undefined') {
      return initialValue; // SSR safety
    }
    
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  const setValue = (value) => {
    try {
      setStoredValue(value);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(value));
      }
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };
  
  return [storedValue, setValue];
}

// ✅ GOOD: Document with JSDoc
/**
 * Custom hook for managing a counter
 * @param {number} initialValue - Initial counter value
 * @returns {Object} Object with count, increment, decrement, and reset
 */
function useCounter(initialValue = 0) {
  // Implementation
}

// ✅ GOOD: Use useCallback for stable references
function useApiCall(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  
  const fetchData = useCallback(async () => {
    setLoading(true);
    try {
      const response = await fetch(url);
      const data = await response.json();
      setData(data);
    } finally {
      setLoading(false);
    }
  }, [url]);
  
  return { data, loading, fetchData };
}

Follow best practices: keep hooks focused, return appropriate structures, handle edge cases, provide cleanup, and document your hooks. Use useCallback/useMemo when needed for performance.

Conclusion

Custom hooks are a powerful way to share logic between components. Start hook names with 'use', keep them focused, handle cleanup properly, and compose them for complex functionality. Follow the rules of hooks, test your hooks thoroughly, and document their behavior. Remember: custom hooks let you extract and reuse stateful logic, making your components cleaner and more maintainable.