Custom Hooks in React: A Complete Guide

Learn how to extract and reuse stateful logic across components with custom hooks. Build cleaner, more maintainable React applications.

Introduction

If you've worked with React for any length of time, you've likely encountered the same pattern of logic repeated across multiple components. Maybe you've copied and pasted that data fetching code, or perhaps you've struggled with prop drilling to pass callbacks several levels deep. Custom hooks offer an elegant solution to these common challenges.

Custom hooks are JavaScript functions that start with "use" and can call other React hooks. They allow you to extract component logic into reusable functions, solving the same problems that higher-order components and render props solved, but with a more straightforward approach that aligns with how developers think about functions.

In this guide, we'll explore the motivation behind custom hooks, walk through building several practical examples, and establish best practices that will help you write production-quality custom hooks. To get the most from this guide, you should be familiar with fundamental React hooks like useState and useEffect.

Why Custom Hooks Matter

The Problem with Duplicated Logic

Before custom hooks became available, React developers faced a fundamental challenge: how to share stateful logic between components without creating excessive abstraction layers. Consider a scenario where multiple components need to track the window size for responsive behavior. Without custom hooks, you'd have two main options, both with significant drawbacks.

The first option was duplicating the useEffect and useState code in every component that needed window size tracking. This approach leads to maintenance nightmares--any change to the implementation requires updating every occurrence, and inconsistencies easily slip in.

The second option involved higher-order components or render props, which added complexity and made the component hierarchy harder to follow. These patterns required understanding component composition in ways that weren't intuitive for many developers.

The DRY Principle Applied to React

Custom hooks bring the Don't Repeat Yourself principle to React's stateful logic. A well-designed custom hook encapsulates a piece of behavior once and makes it available throughout your application. The benefits extend beyond mere code reduction.

Testing becomes straightforward when logic lives in custom hooks. You can verify behavior in isolation without rendering full components. Components that use custom hooks become more focused on presentation, improving both readability and maintainability. When a bug is discovered in shared logic, fixing it in one place resolves the issue everywhere that hook is used.

Horizontal vs. Vertical Reuse

Understanding the distinction between horizontal and vertical code reuse helps clarify when custom hooks are the right tool. Component composition handles vertical reuse--placing UI elements and structural patterns in parent components. Custom hooks handle horizontal reuse, sharing behavior across components regardless of where they appear in your component tree.

A modal component might use composition to inherit its header and footer from a base modal component. But the logic for detecting clicks outside that modal belongs in a custom hook, reusable by dropdowns, tooltips, and other components that need the same behavior.

DEV Community's comprehensive React hooks guide

Building Your First Custom Hook

The Anatomy of a Custom Hook

Creating a custom hook follows a simple pattern: write a JavaScript function that uses React hooks and returns values for consumers to use. The function name must start with "use"--this isn't just a convention but a requirement that React's linter uses to enforce the rules of hooks.

Let's build a useWindowSize hook that tracks the browser window dimensions. This is a common requirement for responsive applications, and the implementation demonstrates several key concepts you'll encounter in custom hook development.

The hook needs to maintain current dimensions in state, subscribe to window resize events, and clean up properly when the component unmounts or dependencies change. Each of these responsibilities maps directly to React concepts you already know from useState and useEffect, combined into a reusable package.

Complete Implementation Example

import { useState, useEffect } from 'react';

interface WindowSize {
 width: number;
 height: number;
}

function useWindowSize(): WindowSize {
 const [windowSize, setWindowSize] = useState<WindowSize>({
 width: typeof window !== 'undefined' ? window.innerWidth : 0,
 height: typeof window !== 'undefined' ? window.innerHeight : 0,
 });

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

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

 return windowSize;
}

This example demonstrates several important patterns. The TypeScript interface makes the return value self-documenting. The empty dependency array in useEffect ensures the subscription is created once and cleaned up properly. The typeof checks handle server-side rendering gracefully, preventing errors during Next.js or similar framework initialization.

Using Your Custom Hook

Using the hook in a component is straightforward:

function ResponsiveComponent() {
 const { width, height } = useWindowSize();
 
 return (
 <div>
 <p>Window width: {width}px</p>
 <p>Window height: {height}px</p>
 </div>
 );
}

The component doesn't need to know anything about event listeners or state management--it simply receives the current window dimensions and uses them. This separation of concerns is the essence of what makes custom hooks powerful.

NamasteDev's practical custom hooks guide

Essential Custom Hook Patterns

Data Fetching with useFetch

Data fetching is perhaps the most common use case for custom hooks. Every application needs to load data from APIs, and the pattern of loading, success, and error states repeats endlessly. A well-designed useFetch hook encapsulates this pattern while remaining flexible enough for various API shapes.

function useFetch<T>(url: string) {
 const [data, setData] = useState<T | null>(null);
 const [isLoading, setIsLoading] = useState(true);
 const [error, setError] = useState<Error | null>(null);

 useEffect(() => {
 const controller = new AbortController();
 
 async function fetchData() {
 try {
 setIsLoading(true);
 const response = await fetch(url, {
 signal: controller.signal,
 });
 
 if (!response.ok) {
 throw new Error(`HTTP error! status: ${response.status}`);
 }
 
 const json = await response.json();
 setData(json);
 setError(null);
 } catch (err) {
 if (err instanceof Error && err.name !== 'AbortError') {
 setError(err);
 }
 } finally {
 setIsLoading(false);
 }
 }

 fetchData();
 
 return () => controller.abort();
 }, [url]);

 return { data, isLoading, error };
}

The AbortController integration is crucial for production applications. It prevents race conditions when components unmount before fetch completes, ensuring your application doesn't try to update state on unmounted components. This pattern becomes essential as applications grow and data fetching becomes more complex.

Form Management with useForm

Forms require significant boilerplate in any React application. A useForm hook reduces this boilerplate while providing a consistent interface for form handling across your application. Combined with useReducer for complex form state, you can build robust form handling systems.

function useForm<T>(initialValues: T) {
 const [values, setValues] = useState<T>(initialValues);
 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
 const [touched, setTouched] = useState<Set<keyof T>>(new Set());

 const handleChange = (name: keyof T, value: string) => {
 setValues(prev => ({ ...prev, [name]: value }));
 };

 const handleBlur = (name: keyof T) => {
 setTouched(prev => new Set([...prev, name]));
 };

 const reset = () => {
 setValues(initialValues);
 setErrors({});
 setTouched(new Set());
 };

 return {
 values,
 errors,
 touched,
 handleChange,
 handleBlur,
 reset,
 };
}

This hook centralizes form state management while remaining agnostic to the specific form validation rules your application needs. Consumers can add validation logic on top of this foundation, keeping the hook focused on state management rather than business logic.

Telerik's React design patterns guide

Debouncing with useDebounce

Debouncing limits how often a function executes, which is essential for expensive operations like API calls or heavy computations. A useDebounce hook wraps this logic in a reusable package.

function useDebounce<T>(value: T, delay: number): T {
 const [debouncedValue, setDebouncedValue] = useState<T>(value);

 useEffect(() => {
 const handler = setTimeout(() => {
 setDebouncedValue(value);
 }, delay);

 return () => {
 clearTimeout(handler);
 };
 }, [value, delay]);

 return debouncedValue;
}

This hook is invaluable for search inputs, where you want to trigger searches as the user types but not on every keystroke. The calling component simply passes its value and desired delay, receiving the debounced version automatically.

Detecting Outside Clicks with useOnClickOutside

Modals, dropdowns, and tooltips all need to detect when the user clicks outside their boundaries. The useOnClickOutside hook encapsulates this pattern:

function useOnClickOutside(
 ref: RefObject<HTMLElement>,
 handler: (event: MouseEvent | TouchEvent) => void
) {
 useEffect(() => {
 const listener = (event: MouseEvent | TouchEvent) => {
 if (!ref.current || ref.current.contains(event.target as Node)) {
 return;
 }
 handler(event);
 };

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

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

The ref pattern might seem unusual at first, but it provides maximum flexibility. Components can use the hook with any element's ref, whether it's a div, button, or custom component.

Best Practices for Custom Hooks

Naming Conventions

The "use" prefix isn't just React convention--it's how the linter identifies hook violations. Your hook names should be descriptive enough that consumers understand their purpose without reading documentation. A hook named useFetchData clearly indicates it fetches data. A hook named useUtils tells nothing about what it does.

Consider the consumer's perspective when naming. When a developer types const { data } = useSomething(), they should have a reasonable expectation of what they're getting. Avoid generic names that could apply to multiple behaviors, and prefer verbs or action phrases that indicate what the hook does.

Single Responsibility Principle

A hook should do one thing and do it well. When you find yourself adding configuration options for different behaviors, consider whether those behaviors should be separate hooks composed together. This principle has several practical benefits.

Testing becomes dramatically simpler when hooks have single responsibilities. A hook that only handles local storage can be tested without involving API calls. A hook that only manages debouncing can be verified independently of form state. These focused hooks can then be combined into more complex behaviors when needed.

The single responsibility principle also makes hooks more reusable. A form-specific storage hook might only be useful in forms, but a general localStorage hook can be used anywhere that needs persistence. Think about what the truly shared behavior is, not just what's repeated in your current components.

Return Only What Consumers Need

Exposing implementation details through your hook's return value creates coupling between the hook and its consumers. If you change how something is implemented, every component using the hook might need updates. Return the minimum necessary values with clear, stable interfaces.

When returning multiple values, consider using an object rather than an array. Object destructuring allows consumers to pick only what they need while still providing meaningful names:

// Prefer this:
return { data, isLoading, error, refetch };

// Over this:
return [data, setData, isLoading, setIsLoading, error, setError];

The object approach lets consumers write const { data } = useMyHook() and ignore everything else. If you return an array, taking the second element means const [, setData] = useMyHook(), which loses the semantic meaning.

Managing Dependencies Correctly

The useEffect dependency array is where many custom hook bugs originate. The ESLint plugin for React hooks helps, but understanding why dependencies matter prevents subtle issues.

If your hook returns a callback that references values from closure, those values must appear in the dependency array. Missing dependencies lead to stale closures where your callback sees outdated values. The solution is often using useCallback for stable function references, but sometimes restructuring the hook to minimize dependencies is cleaner.

NamasteDev's practical custom hooks guide

Performance Considerations

Memoization Within Custom Hooks

Custom hooks can inadvertently cause performance problems if not designed carefully. The primary concern is returning unstable references that cause consumers to re-render unnecessarily. When a hook returns an object or array, React compares references on each render.

If your hook creates a new object on every render, any component using that hook will re-render whenever the parent re-renders--even if the actual data hasn't changed. The useMemo hook within your custom hook can prevent this. Well-optimized hooks contribute to better overall application performance, which is essential for effective SEO strategies since site speed is a ranking factor.

function useExpensiveCalculation(data: Data[]) {
 const result = useMemo(() => {
 return data.map(item => heavyComputation(item));
 }, [data]);

 const helpers = useMemo(() => ({
 sort: (key: keyof Data) => [...result].sort((a, b) => a[key] > b[key] ? 1 : -1),
 filter: (predicate: (item: Result) => boolean) => result.filter(predicate),
 }), [result]);

 return { result, helpers };
}

The key insight is that memoization isn't just for expensive computations--it's for maintaining stable references. The helpers object is memoized because it shouldn't change unless result changes, preventing unnecessary re-renders of child components that depend on it.

Avoiding Unnecessary Work

Some hooks can detect when their work isn't needed and skip it entirely. A hook that fetches data might skip the fetch if the URL is empty. A hook that subscribes to events might skip subscription if the component is already unmounted.

Lazy initialization is another optimization technique. If your initial state requires expensive computation, provide a function instead of the value itself:

// Instead of:
const [data] = useState(computeExpensiveInitialState());

// Use:
const [data] = useState(() => computeExpensiveInitialState());

React calls the function only once during the initial render, not on every render. This optimization matters most for hooks that might be called in components that re-render frequently.

Preventing Memory Leaks

Memory leaks in custom hooks typically occur from subscriptions that aren't cleaned up. Event listeners, timers, network requests, and observers all need cleanup. The useEffect cleanup function exists precisely for this purpose.

When implementing cleanup, think about all the ways your hook might need to unsubscribe. A fetch request might complete after unmount. An event listener might fire after cleanup. A timer might trigger after the component is gone. Testing your hook under rapid mount/unmount cycles helps catch these issues before they reach production.

DEV Community's comprehensive React hooks guide

TypeScript and Custom Hooks

Generics for Flexible Hooks

TypeScript generics make hooks reusable across different data types. A useLocalStorage hook that only works with strings is less useful than one that works with any type:

function useLocalStorage<T>(key: string, initialValue: T) {
 const [storedValue, setStoredValue] = useState<T>(() => {
 try {
 const item = window.localStorage.getItem(key);
 return item ? JSON.parse(item) : initialValue;
 } catch (error) {
 console.error(error);
 return initialValue;
 }
 });

 const setValue = (value: T | ((val: T) => T)) => {
 try {
 const valueToStore = value instanceof Function ? value(storedValue) : value;
 setStoredValue(valueToStore);
 window.localStorage.setItem(key, JSON.stringify(valueToStore));
 } catch (error) {
 console.error(error);
 }
 };

 return [storedValue, setValue] as const;
}

The generic type T flows through the entire hook, providing type safety for both reading and writing. Consumers get full IntelliSense for the stored value type:

interface UserPreferences {
 theme: 'light' | 'dark';
 fontSize: number;
}

const [prefs, setPrefs] = useLocalStorage<UserPreferences>('preferences', {
 theme: 'light',
 fontSize: 16,
});

// TypeScript knows prefs.theme is 'light' | 'dark'

Type Inference

TypeScript often infers types automatically, reducing the explicit type annotations you need to write. In many cases, simply providing an initial value allows TypeScript to determine the type:

// TypeScript infers { count: number }
const [state, setState] = useState({ count: 0 });

// TypeScript infers string[]
const [items, setItems] = useState<string[]>([]);

However, explicit types become important for public hooks or hooks distributed as libraries. Consumers benefit from seeing what types they should provide, and explicit types catch integration errors earlier in development.

Telerik's React design patterns guide

Testing Custom Hooks

Testing Library Approach

The @testing-library/react-hooks library provides utilities specifically designed for testing hooks. The renderHook function creates a test component that uses your hook without the visual rendering:

import { renderHook, cleanup } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
 afterEach(cleanup);

 it('initializes with given value', () => {
 const { result } = renderHook(() => useCounter(10));
 expect(result.current.count).toBe(10);
 });

 it('increments the counter', () => {
 const { result } = renderHook(() => useCounter(0));
 
 act(() => {
 result.current.increment();
 });
 
 expect(result.current.count).toBe(1);
 });

 it('decrements the counter', () => {
 const { result } = renderHook(() => useCounter(5));
 
 act(() => {
 result.current.decrement();
 });
 
 expect(result.current.count).toBe(4);
 });
});

The act function wraps interactions that trigger state updates, ensuring tests run synchronously and match how React behaves in browsers. This pattern works for both synchronous and asynchronous hooks.

Testing Async Hooks

Async hooks require waiting for promises to resolve. The waitFor utility from testing-library helps:

import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

describe('useFetch', () => {
 it('fetches data successfully', async () => {
 const { result, waitForNextUpdate } = renderHook(() => 
 useFetch('/api/data')
 );

 expect(result.current.isLoading).toBe(true);
 
 await waitFor(() => {
 expect(result.current.isLoading).toBe(false);
 });

 expect(result.current.data).toEqual({ success: true });
 expect(result.current.error).toBeNull();
 });

 it('handles fetch errors', async () => {
 const { result } = renderHook(() => 
 useFetch('/api/nonexistent')
 );

 await waitFor(() => {
 expect(result.current.error).not.toBeNull();
 });
 });
});

Testing Strategies

Test hooks in isolation from the components that use them. This approach verifies hook behavior directly and makes failures easier to diagnose. Mock external dependencies like API responses or localStorage to ensure consistent test results.

Consider testing these scenarios: initial state, all state transitions, error conditions, cleanup behavior, and different input combinations. The goal is confidence that the hook behaves correctly across all expected usage patterns.

Common Pitfalls and Debugging

The Rules of Hooks

React enforces two strict rules for hook usage, and violating them causes undefined behavior. First, hooks must only be called at the top level of your component or custom hook--not inside loops, conditions, or nested functions. Second, hooks must only be called from React function components or other custom hooks.

The first rule exists because React relies on hook call order to maintain state correctly. If you conditionally call a hook, the call order might change between renders, causing state to be associated with the wrong hook. The second rule ensures hooks only interact with React's state and lifecycle systems.

The eslint-plugin-react-hooks package enforces these rules automatically. Enabling it in your editor catches violations immediately, preventing bugs before they reach production.

Debugging Custom Hooks

When hooks don't behave as expected, several techniques help identify the problem. React DevTools displays the current state of all hooks in a component, letting you inspect values and verify they're what you expect:

// Add debug value for easier DevTools inspection
function useMyHook(data) {
 useDebugValue(data, value => 
 `useMyHook: ${value.id}`
 );
 // ... rest of hook
}

The useDebugValue hook adds a label in DevTools, making it easier to identify which hook instance you're examining when multiple instances exist. This is especially helpful in lists of components all using the same hook.

For complex issues, console.log the values at key points in your hook's execution. Pay special attention to dependency arrays and cleanup functions, as these are common sources of bugs. A missing dependency can cause stale closure issues where your hook uses outdated values.

Common Bug Patterns

Several bug patterns recur in custom hook development. Stale closures occur when a callback references a value from a previous render, common when that value isn't in the dependency array. The fix is usually adding the dependency or wrapping the callback in useCallback.

Memory leaks from uncleaned subscriptions cause performance problems and potential errors. Always return a cleanup function from useEffect, and consider using AbortController for async operations.

Race conditions appear when multiple requests complete out of order. The last request might finish before an earlier one, setting stale data. AbortController helps here too, allowing you to cancel pending requests when components unmount or dependencies change.

NamasteDev's practical custom hooks guide

Real-World Use Cases

Authentication State Hook

Applications typically need authentication state scattered throughout the codebase. An authentication hook provides a consistent interface:

interface AuthState {
 user: User | null;
 isAuthenticated: boolean;
 isLoading: boolean;
}

function useAuth() {
 const [authState, setAuthState] = useState<AuthState>({
 user: null,
 isAuthenticated: false,
 isLoading: true,
 });

 const login = async (credentials: LoginCredentials) => {
 setAuthState(prev => ({ ...prev, isLoading: true }));
 try {
 const user = await api.login(credentials);
 setAuthState({ user, isAuthenticated: true, isLoading: false });
 } catch (error) {
 setAuthState(prev => ({ ...prev, isLoading: false }));
 throw error;
 }
 };

 const logout = async () => {
 await api.logout();
 setAuthState({ user: null, isAuthenticated: false, isLoading: false });
 };

 return { ...authState, login, logout };
}

This hook centralizes all authentication logic while providing a simple interface for components. Whether a component needs to check if the user is logged in or trigger a login flow, it uses the same hook with consistent behavior.

Media Query Hook

CSS media queries handle responsive design, but components sometimes need to know which query matches programmatically:

function useMediaQuery(query: string): boolean {
 const [matches, setMatches] = useState(() => 
 typeof window !== 'undefined' 
 ? window.matchMedia(query).matches 
 : false
 );

 useEffect(() => {
 const mediaQuery = window.matchMedia(query);
 const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
 
 mediaQuery.addEventListener('change', handler);
 return () => mediaQuery.removeEventListener('change', handler);
 }, [query]);

 return matches;
}

Components can then make JavaScript-based decisions based on media query matches, enabling responsive behavior that CSS alone couldn't implement.

Conclusion

Custom hooks represent one of React's most powerful features for code organization and reuse. They enable horizontal sharing of stateful logic across components without the complexity of higher-order components or render props. By following established patterns and best practices, you can build hooks that are testable, maintainable, and genuinely reusable.

The key principles to remember are single responsibility, minimal return values, and proper cleanup. Start with simple hooks that solve specific problems, then compose them into more complex behaviors as needed. TypeScript support makes hooks safer to use and easier to understand, while testing utilities ensure your hooks behave correctly.

As you develop your React applications, look for repeated patterns in your components. Each pattern is an opportunity for a custom hook. Extract logic into hooks early, before duplication spreads throughout your codebase. The initial investment pays dividends in maintainability and consistency.

Continue Learning

To deepen your understanding of custom hooks, practice by extracting logic from existing components in your projects. Explore popular hook libraries like SWR and React Query for sophisticated data fetching patterns. Follow React's documentation updates for new hook capabilities in each release.

Mastering custom hooks is essential for building scalable web applications with React. The patterns you learn here translate to other hook-based libraries and frameworks, making your skills valuable across the modern development landscape.

For more advanced patterns, explore how hooks like useReducer and useContext can be combined with custom hooks to manage complex application state.

Sources

  1. DEV Community: The Complete Guide to React Hooks (2025)
  2. Telerik: React Design Patterns and Best Practices for 2025
  3. NamasteDev: Custom Hooks in React: A Guide
  4. React Official Documentation: Custom Hooks

Ready to Build Better React Applications?

Our team of React experts can help you implement custom hooks, optimize performance, and build maintainable component architectures.

Frequently Asked Questions