Custom Hooks: Your React Superpowers

Building Reusable Logic Like a Pro

Welcome to Custom Hooks!

Imagine you're a chef creating a signature sauce. Once you perfect the recipe, you can use it in multiple dishes without remaking it each time. Custom hooks are like your secret sauce recipes in React - they let you extract and reuse component logic across your entire application!

graph TD A[Component Logic] --> B[Extract into Hook] B --> C[Reusable Custom Hook] C --> D[Component 1] C --> E[Component 2] C --> F[Component 3] style A fill:#ff9999 style C fill:#99ff99 style B fill:#9999ff

What Are Custom Hooks?

Custom hooks are JavaScript functions that:

Your First Custom Hook

// Without custom hook - repetitive code
function ProductList() {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{/* render products */}</div>;
}

// With custom hook - clean and reusable
function useFetch(url) {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { loading, error, data };
}

// Now it's super clean!
function ProductList() {
  const { loading, error, data } = useFetch('/api/products');
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{/* render products */}</div>;
}

Common Custom Hook Patterns

1. Form Handling Hook

function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({
      ...prev,
      [name]: true
    }));
  };

  const validateField = (name, value) => {
    // Add your validation logic here
    if (!value) {
      return 'This field is required';
    }
    return '';
  };

  const handleSubmit = (onSubmit) => async (e) => {
    e.preventDefault();
    setIsSubmitting(true);

    // Validate all fields
    const newErrors = {};
    Object.keys(values).forEach(key => {
      const error = validateField(key, values[key]);
      if (error) newErrors[key] = error;
    });

    if (Object.keys(newErrors).length === 0) {
      await onSubmit(values);
    } else {
      setErrors(newErrors);
    }
    
    setIsSubmitting(false);
  };

  const resetForm = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  };

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm
  };
}

// Usage
function LoginForm() {
  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit
  } = useForm({ email: '', password: '' });

  const onSubmit = async (values) => {
    await loginAPI(values);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && <span>{errors.email}</span>}
      </div>
      <div>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.password && errors.password && <span>{errors.password}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

2. Local Storage Hook

function useLocalStorage(key, initialValue) {
  // Get from local storage then parse stored json or return initialValue
  const readValue = () => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };

  // State to store our value
  const [storedValue, setStoredValue] = useState(readValue);

  // Return a wrapped version of useState's setter function that persists the new value to localStorage
  const setValue = (value) => {
    try {
      // Allow value to be a function so we have same API as useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      
      // Save state
      setStoredValue(valueToStore);
      
      // Save to local storage
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue];
}

// Usage
function DarkModeToggle() {
  const [isDarkMode, setIsDarkMode] = useLocalStorage('darkMode', false);

  return (
    <button onClick={() => setIsDarkMode(prev => !prev)}>
      {isDarkMode ? '🌞 Light Mode' : '🌙 Dark Mode'}
    </button>
  );
}

3. Window Size Hook

function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });

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

    // Add event listener
    window.addEventListener('resize', handleResize);
    
    // Call handler right away so state gets updated with initial window size
    handleResize();
    
    // Remove event listener on cleanup
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty array ensures effect only runs on mount

  return windowSize;
}

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

4. Debounce Hook

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

  useEffect(() => {
    // Set debouncedValue to value after the specified delay
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cancel the timeout if value changes (also on component unmount)
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);
  
  // Fetch results using the debounced search term
  useEffect(() => {
    if (debouncedSearchTerm) {
      // API call here
      searchAPI(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
}

Advanced Custom Hook Patterns

1. Async Data Hook with Caching

const cache = new Map();

function useAsyncData(key, fetchFn, options = {}) {
  const { 
    cacheTime = 5 * 60 * 1000, // 5 minutes default
    refetchOnMount = false,
    refetchInterval = null
  } = options;
  
  const [state, setState] = useState({
    data: cache.get(key)?.data ?? null,
    loading: !cache.has(key),
    error: null,
    lastUpdated: cache.get(key)?.timestamp ?? null
  });

  const fetchData = async () => {
    setState(prev => ({ ...prev, loading: true }));
    
    try {
      const data = await fetchFn();
      const timestamp = Date.now();
      
      cache.set(key, { data, timestamp });
      
      setState({
        data,
        loading: false,
        error: null,
        lastUpdated: timestamp
      });
    } catch (error) {
      setState(prev => ({
        ...prev,
        loading: false,
        error
      }));
    }
  };

  useEffect(() => {
    const cachedData = cache.get(key);
    const now = Date.now();
    
    if (!cachedData || 
        (cachedData && now - cachedData.timestamp > cacheTime) ||
        refetchOnMount) {
      fetchData();
    }
  }, [key]);

  useEffect(() => {
    if (refetchInterval) {
      const interval = setInterval(fetchData, refetchInterval);
      return () => clearInterval(interval);
    }
  }, [refetchInterval]);

  const refetch = () => {
    cache.delete(key);
    fetchData();
  };

  return { ...state, refetch };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useAsyncData(
    `user-${userId}`,
    () => fetchUser(userId),
    { cacheTime: 10 * 60 * 1000 } // 10 minutes
  );

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}

2. Media Query Hook

function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const media = window.matchMedia(query);
    
    if (media.matches !== matches) {
      setMatches(media.matches);
    }
    
    const listener = () => setMatches(media.matches);
    
    // Modern browsers
    if (media.addEventListener) {
      media.addEventListener('change', listener);
    } else {
      // Fallback for older browsers
      media.addListener(listener);
    }
    
    return () => {
      if (media.removeEventListener) {
        media.removeEventListener('change', listener);
      } else {
        media.removeListener(listener);
      }
    };
  }, [matches, query]);

  return matches;
}

// Convenience hooks
function useBreakpoint() {
  const isMobile = useMediaQuery('(max-width: 640px)');
  const isTablet = useMediaQuery('(min-width: 641px) and (max-width: 1024px)');
  const isDesktop = useMediaQuery('(min-width: 1025px)');
  
  return { isMobile, isTablet, isDesktop };
}

// Usage
function ResponsiveLayout() {
  const { isMobile, isTablet, isDesktop } = useBreakpoint();
  
  return (
    <div>
      {isMobile && <MobileLayout />}
      {isTablet && <TabletLayout />}
      {isDesktop && <DesktopLayout />}
    </div>
  );
}

3. Event Listener Hook

function useEventListener(eventName, handler, element = window) {
  // Create a ref that stores handler
  const savedHandler = useRef();
  
  // Update ref.current value if handler changes
  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    // Make sure element supports addEventListener
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;
    
    // Create event listener that calls handler function stored in ref
    const eventListener = (event) => savedHandler.current(event);
    
    // Add event listener
    element.addEventListener(eventName, eventListener);
    
    // Remove event listener on cleanup
    return () => {
      element.removeEventListener(eventName, eventListener);
    };
  }, [eventName, element]);
}

// Usage: Click outside detector
function useClickOutside(ref, handler) {
  useEventListener('mousedown', (event) => {
    if (!ref.current || ref.current.contains(event.target)) {
      return;
    }
    handler(event);
  });
}

function Dropdown() {
  const dropdownRef = useRef();
  const [isOpen, setIsOpen] = useState(false);
  
  useClickOutside(dropdownRef, () => setIsOpen(false));
  
  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle Dropdown
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          {/* Dropdown content */}
        </div>
      )}
    </div>
  );
}

Composing Custom Hooks

One of the most powerful features of custom hooks is that they can use other hooks, including other custom hooks!

// Compose multiple hooks for authentication
function useAuth() {
  const [user, setUser] = useLocalStorage('user', null);
  const [token, setToken] = useLocalStorage('token', null);
  const { data: userData, loading, error, refetch } = useAsyncData(
    'current-user',
    () => fetchCurrentUser(token),
    { enabled: !!token }
  );

  const login = async (email, password) => {
    try {
      const response = await authAPI.login(email, password);
      setToken(response.token);
      setUser(response.user);
      return response;
    } catch (error) {
      throw error;
    }
  };

  const logout = () => {
    setToken(null);
    setUser(null);
  };

  const isAuthenticated = !!token && !!user;

  return {
    user: userData || user,
    loading,
    error,
    isAuthenticated,
    login,
    logout,
    refetch
  };
}

// Usage
function App() {
  const { user, isAuthenticated, loading } = useAuth();
  
  if (loading) return <LoadingScreen />;
  
  return (
    <Router>
      {isAuthenticated ? <AuthenticatedApp /> : <PublicApp />}
    </Router>
  );
}

Testing Custom Hooks

Custom hooks need to be tested to ensure they work correctly. Here's how to test them:

// useCounter.js
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 };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';

describe('useCounter', () => {
  it('should initialize with the correct value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  it('should increment counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  it('should decrement counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  it('should reset to initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

Best Practices for Custom Hooks

1. Always Start with "use"

// ✅ Good
function useUserData() { ... }
function useAuth() { ... }
function useLocalStorage() { ... }

// ❌ Bad
function getUserData() { ... }
function authHook() { ... }
function storageManager() { ... }

2. Keep Hooks Pure

// ✅ Good - pure function
function usePagination(items, itemsPerPage) {
  const [currentPage, setCurrentPage] = useState(1);
  
  const totalPages = Math.ceil(items.length / itemsPerPage);
  const currentItems = items.slice(
    (currentPage - 1) * itemsPerPage,
    currentPage * itemsPerPage
  );
  
  return {
    currentItems,
    currentPage,
    totalPages,
    setCurrentPage
  };
}

// ❌ Bad - side effects outside of useEffect
function usePagination(items, itemsPerPage) {
  const [currentPage, setCurrentPage] = useState(1);
  
  // Side effect! This will run on every render
  document.title = `Page ${currentPage}`;
  
  // Rest of the logic...
}

3. Return Consistent Values

// ✅ Good - consistent return structure
function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // Always return the same structure
  return { user, loading, error };
}

// ❌ Bad - inconsistent returns
function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  if (loading) return { loading };
  if (user) return { user };
  return null;
}

Practice Exercises

Exercise 1: Create a Countdown Timer Hook

Create a useCountdown hook that:

  • Accepts initial time in seconds
  • Provides start, pause, reset functions
  • Returns remaining time
  • Calls a callback when time reaches zero
// Your implementation
function useCountdown(initialTime, onComplete) {
  // Implement the countdown logic here
  
  return {
    timeLeft,
    isRunning,
    start,
    pause,
    reset
  };
}

Exercise 2: Create an Intersection Observer Hook

Build a hook for lazy loading images or infinite scroll:

// Create useIntersectionObserver hook
function useIntersectionObserver(options = {}) {
  // Implement intersection observer logic
  // Return ref and whether element is visible
}

Exercise 3: Create a WebSocket Hook

Build a hook for WebSocket connections:

  • Connect/disconnect functionality
  • Send messages
  • Receive messages
  • Handle connection status

Real-World Custom Hook Examples

1. Infinite Scroll Hook

function useInfiniteScroll(fetchMore) {
  const [isFetching, setIsFetching] = useState(false);
  const observer = useRef();
  const lastElementRef = useCallback(node => {
    if (isFetching) return;
    
    if (observer.current) observer.current.disconnect();
    
    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) {
        setIsFetching(true);
        fetchMore().finally(() => setIsFetching(false));
      }
    });
    
    if (node) observer.current.observe(node);
  }, [isFetching, fetchMore]);

  return [lastElementRef, isFetching];
}

// Usage
function PostList() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  
  const fetchMorePosts = async () => {
    const newPosts = await fetchPosts(page);
    setPosts(prev => [...prev, ...newPosts]);
    setPage(prev => prev + 1);
  };
  
  const [lastPostRef, isFetching] = useInfiniteScroll(fetchMorePosts);
  
  return (
    <div>
      {posts.map((post, index) => (
        <div 
          key={post.id}
          ref={index === posts.length - 1 ? lastPostRef : null}
        >
          <Post data={post} />
        </div>
      ))}
      {isFetching && <LoadingSpinner />}
    </div>
  );
}

2. Animation Hook

function useAnimation(duration = 300, easing = 'ease-in-out') {
  const [isAnimating, setIsAnimating] = useState(false);
  const [progress, setProgress] = useState(0);
  const animationRef = useRef();
  const startTimeRef = useRef();
  
  const startAnimation = useCallback(() => {
    setIsAnimating(true);
    startTimeRef.current = performance.now();
    
    const animate = (currentTime) => {
      const elapsed = currentTime - startTimeRef.current;
      const progress = Math.min(elapsed / duration, 1);
      
      setProgress(progress);
      
      if (progress < 1) {
        animationRef.current = requestAnimationFrame(animate);
      } else {
        setIsAnimating(false);
      }
    };
    
    animationRef.current = requestAnimationFrame(animate);
  }, [duration]);
  
  const stopAnimation = useCallback(() => {
    if (animationRef.current) {
      cancelAnimationFrame(animationRef.current);
      setIsAnimating(false);
      setProgress(0);
    }
  }, []);
  
  useEffect(() => {
    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
    };
  }, []);
  
  return {
    isAnimating,
    progress,
    startAnimation,
    stopAnimation
  };
}

// Usage
function AnimatedModal({ isOpen, onClose }) {
  const { progress, startAnimation } = useAnimation(300);
  
  useEffect(() => {
    if (isOpen) {
      startAnimation();
    }
  }, [isOpen, startAnimation]);
  
  if (!isOpen && progress === 0) return null;
  
  return (
    <div 
      style={{
        opacity: progress,
        transform: `scale(${0.8 + progress * 0.2})`
      }}
    >
      <div className="modal-content">
        {/* Modal content */}
      </div>
    </div>
  );
}

Key Takeaways

When to Create Custom Hooks