Skip to main content

🎣 Custom Hooks

You've been using React's built-in hooks like useState and useEffect. But what if you could create your own hooks? Custom hooks are JavaScript functions that let you extract and reuse stateful logic between components. Instead of copying and pasting the same useState and useEffect code everywhere, you can package it into a reusable hook with a name like useFetch, useForm, or useLocalStorage. Custom hooks are one of React's most powerful featuresβ€”they let you build your own abstractions and share logic elegantly. Let's learn how to create them! πŸš€

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand what custom hooks are and why they're useful
  • Follow the rules of hooks when creating custom hooks
  • Extract stateful logic into reusable custom hooks
  • Build custom hooks for data fetching
  • Create hooks for form handling and validation
  • Build utility hooks (useLocalStorage, useDebounce, etc.)
  • Type custom hooks properly with TypeScript
  • Compose multiple hooks together
  • Test and debug custom hooks
  • Share hooks across your application

Estimated Time: 75-90 minutes

Project: Build a collection of reusable custom hooks

In This Lesson

πŸ€” What Are Custom Hooks?

Think of React's built-in hooks β€” useState, useEffect, useRef β€” as individual power tools. A custom hook is what you get when you bolt a few of those tools together into a single, purpose-built appliance and give it a friendly name. It's just a JavaScript function, but because it calls other hooks, it inherits their superpowers: it can hold state, run effects, and re-render your component when things change. πŸ”Œ

The payoff is reuse without repetition. Before custom hooks, teams reached for tangled patterns like higher-order components and render props to share stateful logic β€” and those patterns wrapped your component tree in layers of noise. Custom hooks share the logic while leaving your component tree completely flat. You extract the "how," and every component just calls the hook and gets back the "what."

πŸ“– Definition

Custom Hook: A JavaScript function whose name starts with use and that may call other hooks. Custom hooks let you reuse stateful logic between components without changing your component hierarchy β€” no wrappers, no prop drilling, just a function call.

🎬 See It In Action: Extracting Logic into a Custom Hook

Click the button to see how duplicated logic gets extracted into a reusable custom hook:

Component A Duplicated fetch logic in Component A useState(data) useState(loading) useState(error) useEffect(fetch...) Component B Duplicated fetch logic in Component B useState(data) useState(loading) useState(error) useEffect(fetch...) Extract! useFetch Hook useState(data) useState(loading) useState(error) useEffect(fetch...) Component A useFetch('/api/user') Component B useFetch('/api/posts') Component C useFetch('/api/todos')

The Problem: Duplicated Logic

⚠️ Without Custom Hooks

// Component A: Fetching user data
const UserProfile: React.FC = () => {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        fetch('https://api.example.com/user')
            .then(res => res.json())
            .then(setUser)
            .catch(setError)
            .finally(() => setIsLoading(false));
    }, []);
    
    // ... render logic
};

// Component B: Fetching posts data
const PostsList: React.FC = () => {
    const [posts, setPosts] = useState([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        fetch('https://api.example.com/posts')
            .then(res => res.json())
            .then(setPosts)
            .catch(setError)
            .finally(() => setIsLoading(false));
    }, []);
    
    // ... render logic
};

// Problem: Same logic duplicated in both components! 😞

The Solution: Custom Hook

βœ… With Custom Hook

// hooks/useFetch.ts
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(() => {
        fetch(url)
            .then(res => res.json())
            .then(setData)
            .catch(setError)
            .finally(() => setIsLoading(false));
    }, [url]);
    
    return { data, isLoading, error };
}

// Component A: Clean and simple!
const UserProfile: React.FC = () => {
    const { data: user, isLoading, error } = useFetch<User>('/api/user');
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    
    return <div>{user?.name}</div>;
};

// Component B: Reusing the same hook!
const PostsList: React.FC = () => {
    const { data: posts, isLoading, error } = useFetch<Post[]>('/api/posts');
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    
    return <ul>{posts?.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
};

Benefits of Custom Hooks

Why Use Custom Hooks?

Benefit Description Example
Reusability Write logic once, use everywhere useFetch in all components
Separation of Concerns Logic separate from UI Form logic vs form UI
Testability Test logic independently Test useFetch without UI
Readability Self-documenting code useAuth vs 50 lines of auth code
Composition Combine hooks together useAuth + useFetch
Sharing Share across projects/teams Hook libraries

Custom Hooks vs Other Patterns

graph TD A[Need to Share Logic] --> B{"What Kind?"} B -->|Stateful Logic| C[Custom Hook] B -->|UI Structure| D[Component] B -->|Pure Functions| E[Utility Function] B -->|Complex State| F["Context + Custom Hook"] C --> G[useFetch, useForm, useAuth] D --> H[Modal, Card, Button] E --> I[formatDate, validateEmail] F --> J[useTheme, useCart] style C fill:#4CAF50,color:#fff style A fill:#667eea,color:#fff

When to Create a Custom Hook

πŸ’‘ Guidelines

  • βœ… DO create a hook when:
    • Logic is duplicated across multiple components
    • Logic uses other React hooks (useState, useEffect, etc.)
    • Logic would make components cleaner if extracted
    • Logic is complex and deserves a descriptive name
  • ❌ DON'T create a hook when:
    • Logic is only used once (wait until you need it twice)
    • Logic doesn't use any React hooks (use regular function)
    • Logic is very simple (1-2 lines)
    • You're just wrapping a single hook with no additional logic

πŸ“œ Rules of Hooks

Here's the thing that trips up everyone at first: a custom hook isn't magic, and it doesn't get special treatment. Because it calls useState, useEffect, and friends, it has to obey the exact same two Rules of Hooks that those built-in hooks demand. Break them and you don't get a friendly crash β€” you get subtle, intermittent state bugs that are miserable to track down.

Why so strict? React doesn't identify your hooks by name or by variable β€” it identifies them purely by the order they're called on every render. Hook #1 always maps to state slot #1, hook #2 to slot #2, and so on. Anything that could change that order between renders β€” an if, a loop, an early return β€” scrambles the mapping and hands your component the wrong state. Keep the order rock-solid and everything just works. 🧱

Rule 1: Only Call Hooks at the Top Level

Don't Call Hooks Inside Loops, Conditions, or Nested Functions

// ❌ WRONG: Hook inside condition
function useUser(id: number | null) {
    if (id) {
        const [user, setUser] = useState(null); // ERROR!
    }
}

// ❌ WRONG: Hook inside loop
function useMultipleUsers(ids: number[]) {
    const users = ids.map(id => {
        const [user, setUser] = useState(null); // ERROR!
        return user;
    });
}

// βœ… CORRECT: Hook at top level
function useUser(id: number | null) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        if (id) {
            // Condition inside effect is fine
            fetchUser(id).then(setUser);
        }
    }, [id]);
    
    return user;
}

Rule 2: Only Call Hooks from React Functions

Call Hooks from Components or Custom Hooks Only

// ❌ WRONG: Hook in regular function
function fetchUser(id: number) {
    const [user, setUser] = useState(null); // ERROR!
    // Regular functions can't use hooks
}

// βœ… CORRECT: Hook in custom hook
function useUser(id: number) {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        fetchUser(id).then(setUser);
    }, [id]);
    
    return user;
}

// βœ… CORRECT: Hook in component
const UserProfile: React.FC = () => {
    const user = useUser(1);
    return <div>{user?.name}</div>;
};

Rule 3: Custom Hook Names Must Start with "use"

Naming Convention is Mandatory

// ❌ WRONG: Doesn't start with "use"
function fetchData(url: string) {
    const [data, setData] = useState(null);
    // ...
}

// ❌ WRONG: Starts with "use" but not camelCase
function use_data(url: string) {
    const [data, setData] = useState(null);
    // ...
}

// βœ… CORRECT: Starts with "use" in camelCase
function useData(url: string) {
    const [data, setData] = useState(null);
    // ...
}

// βœ… CORRECT: More specific name
function useFetchUser(userId: number) {
    const [user, setUser] = useState(null);
    // ...
}

Spot the Violation

Before we dig into why, let's train your eye. Each button below is a line of code inside a component or custom hook β€” click one and decide for yourself whether it obeys the Rules of Hooks or breaks them.

Does this call obey the Rules of Hooks?

πŸ‘† Click an example to check it against the Rules of Hooks.

Why These Rules Matter

πŸ’‘ Understanding the Rules

React relies on the order hooks are called to maintain state correctly. Breaking these rules causes:

  • State corruption: React loses track of which state belongs to which hook
  • Unpredictable behavior: Different renders may have different hook orders
  • Hard-to-debug errors: Subtle bugs that only appear sometimes
// Why order matters:
function MyComponent() {
    // First render:
    const [count, setCount] = useState(0);    // Hook 1
    const [name, setName] = useState('');     // Hook 2
    
    // React internally tracks: [0, '']
    
    // If you conditionally skip a hook:
    if (someCondition) {
        const [count, setCount] = useState(0); // Sometimes Hook 1
    }
    const [name, setName] = useState('');      // Sometimes Hook 1, sometimes Hook 2!
    
    // React's internal state gets confused! πŸ’₯
}

Let the Linter Enforce It for You

You should never police these rules by hand β€” that's a machine's job. The official eslint-plugin-react-hooks catches every violation as you type, long before the bug can reach a browser.

Automatic Rule Enforcement

# Install the hooks ESLint plugin
npm install eslint-plugin-react-hooks --save-dev

Modern ESLint (v9+) uses flat config in an eslint.config.js file. The plugin ships a ready-made recommended-latest config that turns both rules on for you:

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  // ...your other config...
  reactHooks.configs['recommended-latest'],
];

That single line switches on the two rules that matter:

  • react-hooks/rules-of-hooks (error) β€” no conditional or out-of-order hook calls.
  • react-hooks/exhaustive-deps (warn) β€” flags missing dependencies in useEffect, useCallback, and useMemo.

πŸ’‘ React 19 & the React Compiler

React 19 introduced the React Compiler, which automatically memoizes components and hook return values so you no longer have to hand-wrap everything in useMemo/useCallback. But it only works if your code follows the Rules of Hooks β€” the compiler relies on that predictable call order to reason safely about your components. In other words, the cleaner your hooks, the more React can optimize for free.

πŸ”’ Interactive: How React Tracks Hook Order

React uses the call order to match hooks with their state. Click "Render" to see how React tracks hooks, then try "Conditional Render" to see what breaks!

Component Code function MyComponent () { const [count, setCount] = useState (0) const [name, setName] = useState ('') useEffect (() => {...}, []) if (showExtra) { useState (false) // ⚠️ WRONG! } return <div>...</div> } Render React's Internal State Array Index 0: count = 0 1 Index 1: name = "" 2 Index 2: effect = [callback] 3 ⚠️ Hook order mismatch! πŸ’₯

🎨 Your First Custom Hook

Enough theory β€” let's build one. The recipe for every custom hook, no matter how fancy, is the same three moves: (1) spot some stateful logic living inside a component, (2) lift it out into a function whose name starts with use, and (3) return whatever the component needs back. That's it. Master this loop on a tiny example and the big data-fetching and form hooks later in the lesson will feel like the same idea wearing a bigger coat. πŸ§₯

We'll start with a counter β€” small enough to hold in your head, but real enough to show the whole pattern.

Example: useCounter Hook

Step 1: Identify Reusable Logic

// This counter logic appears in multiple components
const Counter: React.FC = () => {
    const [count, setCount] = useState(0);
    
    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);
    const reset = () => setCount(0);
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
            <button onClick={reset}>Reset</button>
        </div>
    );
};

Step 2: Extract Logic into Hook

// hooks/useCounter.ts
import { useState } from 'react';

function useCounter(initialValue: number = 0) {
    const [count, setCount] = useState(initialValue);
    
    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);
    const reset = () => setCount(initialValue);
    
    return { count, increment, decrement, reset };
}

export default useCounter;

Step 3: Use the Hook

import useCounter from './hooks/useCounter';

const Counter: React.FC = () => {
    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>
    );
};

// Now reusable everywhere!
const AnotherComponent: React.FC = () => {
    const likes = useCounter(0);
    const views = useCounter(100);
    
    return (
        <div>
            <p>Likes: {likes.count}</p>
            <button onClick={likes.increment}>πŸ‘</button>
            
            <p>Views: {views.count}</p>
        </div>
    );
};

Enhancing the Hook

Adding More Features

// Enhanced useCounter with more options
interface UseCounterOptions {
    min?: number;
    max?: number;
    step?: number;
}

function useCounter(initialValue: number = 0, options: UseCounterOptions = {}) {
    const { min, max, step = 1 } = options;
    const [count, setCount] = useState(initialValue);
    
    const increment = () => {
        setCount(prev => {
            const next = prev + step;
            if (max !== undefined && next > max) return max;
            return next;
        });
    };
    
    const decrement = () => {
        setCount(prev => {
            const next = prev - step;
            if (min !== undefined && next < min) return min;
            return next;
        });
    };
    
    const reset = () => setCount(initialValue);
    
    const set = (value: number) => {
        if (min !== undefined && value < min) {
            setCount(min);
        } else if (max !== undefined && value > max) {
            setCount(max);
        } else {
            setCount(value);
        }
    };
    
    return { count, increment, decrement, reset, set };
}

// Usage with options
const Counter: React.FC = () => {
    const { count, increment, decrement } = useCounter(0, {
        min: 0,
        max: 10,
        step: 2
    });
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={increment}>+2</button>
            <button onClick={decrement}>-2</button>
        </div>
    );
};

Hook Anatomy

graph TD A[Custom Hook Function] --> B["Parameters/Config"] A --> C[Internal State - useState] A --> D[Side Effects - useEffect] A --> E[Helper Functions] A --> F["Return Values/Methods"] F --> G[State Values] F --> H[Update Functions] F --> I[Utility Methods] style A fill:#667eea,color:#fff style F fill:#4CAF50,color:#fff

βœ… Custom Hook Pattern

function useCustomHook(parameters) {
    // 1. Internal state
    const [state, setState] = useState(initialValue);
    
    // 2. Side effects
    useEffect(() => {
        // Effects based on parameters or state
    }, [dependencies]);
    
    // 3. Helper functions
    const helperFunction = () => {
        // Logic that uses/updates state
    };
    
    // 4. Return value (object or array)
    return { state, helperFunction };
    // or
    return [state, helperFunction];
}

🌐 Data Fetching Hooks

If custom hooks have a "hello world," this is it. Almost every component that talks to a server juggles the same three pieces of state β€” the data, is it still loading?, and did it blow up? β€” plus an effect to kick off the request. Copy-pasting that dance into every component is exactly the kind of duplication custom hooks were born to erase. We'll build useFetch up in layers, each one solving a real problem the previous version left open.

⚠️ Great for learning, not for production

The hooks below are the perfect way to understand what a data-fetching abstraction actually does. In a real React 19 app, though, reach for a battle-tested library like TanStack Query or your framework's loader β€” they hand you caching, deduping, retries, and background refetching that a hand-rolled hook won't. React also now ships the use() API for unwrapping promises directly inside components (typically fed by a Suspense-friendly data source). Learn the pattern here; lean on the ecosystem in production.

πŸ”„ Visualize: The Data Fetching Lifecycle

Watch how a custom useFetch hook manages state through the entire fetch lifecycle:

Idle data: null loading: false Loading data: null loading: true Success data: {...} loading: false Error error: Error loading: false Refetch fetch() 200 OK Error! refetch()

Basic useFetch Hook

Simple Data Fetching

// hooks/useFetch.ts
import { useState, useEffect } from 'react';

interface UseFetchResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
}

function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);
    
    useEffect(() => {
        const fetchData = async () => {
            try {
                setIsLoading(true);
                const response = await fetch(url);
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const json = await response.json();
                setData(json);
            } catch (err) {
                setError(err instanceof Error ? err : new Error('An error occurred'));
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
    }, [url]);
    
    return { data, isLoading, error };
}

export default useFetch;

// Usage
const UserProfile: React.FC = () => {
    const { data: user, isLoading, error } = useFetch<User>(
        'https://jsonplaceholder.typicode.com/users/1'
    );
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    
    return <div>{user?.name}</div>;
};

Enhanced useFetch with Cancellation

Adding AbortController

function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                setError(null);
                
                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);
            } catch (err) {
                // Don't set error if aborted
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        // Cleanup: cancel request
        return () => {
            controller.abort();
        };
    }, [url]);
    
    return { data, isLoading, error };
}

Advanced useFetch with Refetch

Adding Manual Refetch Capability

interface UseFetchResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);
    const [refetchIndex, setRefetchIndex] = useState(0);
    
    useEffect(() => {
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                setError(null);
                
                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);
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        return () => controller.abort();
    }, [url, refetchIndex]); // Refetch when refetchIndex changes
    
    const refetch = () => {
        setRefetchIndex(prev => prev + 1);
    };
    
    return { data, isLoading, error, refetch };
}

// Usage
const UserProfile: React.FC = () => {
    const { data: user, isLoading, error, refetch } = useFetch<User>('/api/user');
    
    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <button onClick={refetch}>Refresh</button>
        </div>
    );
};

Configurable useFetch

Adding Options and POST Support

interface UseFetchOptions {
    method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
    headers?: Record<string, string>;
    body?: any;
    enabled?: boolean; // Only fetch if true
}

interface UseFetchResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    refetch: () => void;
}

function useFetch<T>(
    url: string,
    options: UseFetchOptions = {}
): UseFetchResult<T> {
    const { method = 'GET', headers, body, enabled = true } = options;
    
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(enabled);
    const [error, setError] = useState<Error | null>(null);
    const [refetchIndex, setRefetchIndex] = useState(0);
    
    useEffect(() => {
        if (!enabled) {
            setIsLoading(false);
            return;
        }
        
        const controller = new AbortController();
        
        const fetchData = async () => {
            try {
                setIsLoading(true);
                setError(null);
                
                const fetchOptions: RequestInit = {
                    method,
                    headers: {
                        'Content-Type': 'application/json',
                        ...headers
                    },
                    signal: controller.signal
                };
                
                if (body && method !== 'GET') {
                    fetchOptions.body = JSON.stringify(body);
                }
                
                const response = await fetch(url, fetchOptions);
                
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                
                const json = await response.json();
                setData(json);
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err);
                }
            } finally {
                setIsLoading(false);
            }
        };
        
        fetchData();
        
        return () => controller.abort();
    }, [url, method, enabled, refetchIndex]);
    
    const refetch = () => {
        setRefetchIndex(prev => prev + 1);
    };
    
    return { data, isLoading, error, refetch };
}

// Usage examples
const UserProfile: React.FC = () => {
    // Simple GET
    const { data: user } = useFetch<User>('/api/user');
    
    // POST request
    const createUser = useFetch<User>('/api/users', {
        method: 'POST',
        body: { name: 'John', email: 'john@example.com' },
        enabled: false // Don't fetch on mount
    });
    
    // Conditional fetch
    const [shouldFetch, setShouldFetch] = useState(false);
    const { data: posts } = useFetch<Post[]>('/api/posts', {
        enabled: shouldFetch
    });
    
    return <div>...</div>;
};

useAPI Hook for Multiple Endpoints

RESTful API Helper

interface UseAPIResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    get: (id?: string | number) => Promise<void>;
    post: (body: Partial<T>) => Promise<void>;
    put: (id: string | number, body: Partial<T>) => Promise<void>;
    del: (id: string | number) => Promise<void>;
}

function useAPI<T>(baseUrl: string): UseAPIResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
    
    const request = async (url: string, options: RequestInit = {}) => {
        try {
            setIsLoading(true);
            setError(null);
            
            const response = await fetch(url, {
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers
                },
                ...options
            });
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            const json = await response.json();
            setData(json);
        } catch (err) {
            setError(err instanceof Error ? err : new Error('An error occurred'));
            throw err;
        } finally {
            setIsLoading(false);
        }
    };
    
    const get = async (id?: string | number) => {
        const url = id ? `${baseUrl}/${id}` : baseUrl;
        await request(url);
    };
    
    const post = async (body: Partial<T>) => {
        await request(baseUrl, {
            method: 'POST',
            body: JSON.stringify(body)
        });
    };
    
    const put = async (id: string | number, body: Partial<T>) => {
        await request(`${baseUrl}/${id}`, {
            method: 'PUT',
            body: JSON.stringify(body)
        });
    };
    
    const del = async (id: string | number) => {
        await request(`${baseUrl}/${id}`, {
            method: 'DELETE'
        });
    };
    
    return { data, isLoading, error, get, post, put, del };
}

// Usage
const UserManager: React.FC = () => {
    const users = useAPI<User>('https://api.example.com/users');
    
    const loadUser = () => {
        users.get(1); // GET /users/1
    };
    
    const createUser = () => {
        users.post({ name: 'John', email: 'john@example.com' });
    };
    
    const updateUser = () => {
        users.put(1, { name: 'John Smith' });
    };
    
    const deleteUser = () => {
        users.del(1);
    };
    
    return (
        <div>
            <button onClick={loadUser}>Load User</button>
            <button onClick={createUser}>Create User</button>
            {users.data && <div>{users.data.name}</div>}
        </div>
    );
};

πŸ“ Form Handling Hooks

Forms are where React's boilerplate really piles up: a useState per field, an onChange per input, a submit handler that has to call preventDefault, and validation logic sprinkled on top. A well-typed useForm hook packages all of that once, so a new form becomes "list the fields, drop in the inputs, done." Watch how TypeScript generics let a single hook adapt to any shape of form β€” a login form, a signup form, a checkout β€” while still giving you autocomplete on every field name.

useForm Hook

Simple Form State Management

interface UseFormResult<T> {
    values: T;
    handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
    handleSubmit: (callback: (values: T) => void) => (e: React.FormEvent) => void;
    reset: () => void;
}

function useForm<T extends Record<string, any>>(
    initialValues: T
): UseFormResult<T> {
    const [values, setValues] = useState<T>(initialValues);
    
    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    ) => {
        const { name, value, type } = e.target;
        
        setValues(prev => ({
            ...prev,
            [name]: type === 'number' ? Number(value) : value
        }));
    };
    
    const handleSubmit = (callback: (values: T) => void) => {
        return (e: React.FormEvent) => {
            e.preventDefault();
            callback(values);
        };
    };
    
    const reset = () => {
        setValues(initialValues);
    };
    
    return { values, handleChange, handleSubmit, reset };
}

// Usage
interface LoginForm {
    email: string;
    password: string;
}

const LoginPage: React.FC = () => {
    const form = useForm<LoginForm>({
        email: '',
        password: ''
    });
    
    const onSubmit = (values: LoginForm) => {
        console.log('Login:', values);
        // Call login API
    };
    
    return (
        <form onSubmit={form.handleSubmit(onSubmit)}>
            <input
                type="email"
                name="email"
                value={form.values.email}
                onChange={form.handleChange}
                placeholder="Email"
            />
            
            <input
                type="password"
                name="password"
                value={form.values.password}
                onChange={form.handleChange}
                placeholder="Password"
            />
            
            <button type="submit">Login</button>
            <button type="button" onClick={form.reset}>Reset</button>
        </form>
    );
};

useForm with Validation

Adding Validation Logic

type ValidationRules<T> = {
    [K in keyof T]?: (value: T[K]) => string | undefined;
};

interface UseFormWithValidationResult<T> {
    values: T;
    errors: Partial<Record<keyof T, string>>;
    handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
    handleSubmit: (callback: (values: T) => void) => (e: React.FormEvent) => void;
    reset: () => void;
    isValid: boolean;
}

function useFormWithValidation<T extends Record<string, any>>(
    initialValues: T,
    validationRules: ValidationRules<T>
): UseFormWithValidationResult<T> {
    const [values, setValues] = useState<T>(initialValues);
    const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
    const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
    
    const validate = (name: keyof T, value: any): string | undefined => {
        const rule = validationRules[name];
        return rule ? rule(value) : undefined;
    };
    
    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    ) => {
        const { name, value, type } = e.target;
        const fieldName = name as keyof T;
        const fieldValue = type === 'number' ? Number(value) : value;
        
        setValues(prev => ({
            ...prev,
            [fieldName]: fieldValue
        }));
        
        // Validate on change if field was touched
        if (touched[fieldName]) {
            const error = validate(fieldName, fieldValue);
            setErrors(prev => ({
                ...prev,
                [fieldName]: error
            }));
        }
    };
    
    const handleBlur = (name: keyof T) => {
        setTouched(prev => ({ ...prev, [name]: true }));
        const error = validate(name, values[name]);
        setErrors(prev => ({ ...prev, [name]: error }));
    };
    
    const validateAll = (): boolean => {
        const newErrors: Partial<Record<keyof T, string>> = {};
        let isValid = true;
        
        Object.keys(validationRules).forEach(key => {
            const fieldName = key as keyof T;
            const error = validate(fieldName, values[fieldName]);
            if (error) {
                newErrors[fieldName] = error;
                isValid = false;
            }
        });
        
        setErrors(newErrors);
        return isValid;
    };
    
    const handleSubmit = (callback: (values: T) => void) => {
        return (e: React.FormEvent) => {
            e.preventDefault();
            
            if (validateAll()) {
                callback(values);
            }
        };
    };
    
    const reset = () => {
        setValues(initialValues);
        setErrors({});
        setTouched({});
    };
    
    const isValid = Object.keys(errors).length === 0;
    
    return {
        values,
        errors,
        handleChange,
        handleSubmit,
        reset,
        isValid
    };
}

// Usage
interface SignupForm {
    username: string;
    email: string;
    password: string;
    confirmPassword: string;
}

const SignupPage: React.FC = () => {
    const form = useFormWithValidation<SignupForm>(
        {
            username: '',
            email: '',
            password: '',
            confirmPassword: ''
        },
        {
            username: (value) => {
                if (!value) return 'Username is required';
                if (value.length < 3) return 'Username must be at least 3 characters';
                return undefined;
            },
            email: (value) => {
                if (!value) return 'Email is required';
                if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid';
                return undefined;
            },
            password: (value) => {
                if (!value) return 'Password is required';
                if (value.length < 8) return 'Password must be at least 8 characters';
                return undefined;
            },
            confirmPassword: (value) => {
                if (value !== form.values.password) return 'Passwords do not match';
                return undefined;
            }
        }
    );
    
    const onSubmit = (values: SignupForm) => {
        console.log('Signup:', values);
    };
    
    return (
        <form onSubmit={form.handleSubmit(onSubmit)}>
            <div>
                <input
                    type="text"
                    name="username"
                    value={form.values.username}
                    onChange={form.handleChange}
                    placeholder="Username"
                />
                {form.errors.username && <span>{form.errors.username}</span>}
            </div>
            
            <div>
                <input
                    type="email"
                    name="email"
                    value={form.values.email}
                    onChange={form.handleChange}
                    placeholder="Email"
                />
                {form.errors.email && <span>{form.errors.email}</span>}
            </div>
            
            <div>
                <input
                    type="password"
                    name="password"
                    value={form.values.password}
                    onChange={form.handleChange}
                    placeholder="Password"
                />
                {form.errors.password && <span>{form.errors.password}</span>}
            </div>
            
            <div>
                <input
                    type="password"
                    name="confirmPassword"
                    value={form.values.confirmPassword}
                    onChange={form.handleChange}
                    placeholder="Confirm Password"
                />
                {form.errors.confirmPassword && <span>{form.errors.confirmPassword}</span>}
            </div>
            
            <button type="submit" disabled={!form.isValid}>Sign Up</button>
        </form>
    );
};

useInput Hook

Single Input State Management

interface UseInputResult {
    value: string;
    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
    reset: () => void;
    setValue: (value: string) => void;
}

function useInput(initialValue: string = ''): UseInputResult {
    const [value, setValue] = useState(initialValue);
    
    const onChange = (
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    ) => {
        setValue(e.target.value);
    };
    
    const reset = () => {
        setValue(initialValue);
    };
    
    return { value, onChange, reset, setValue };
}

// Usage
const SearchBar: React.FC = () => {
    const search = useInput('');
    const email = useInput('user@example.com');
    
    return (
        <div>
            <input
                type="search"
                value={search.value}
                onChange={search.onChange}
                placeholder="Search..."
            />
            
            <input
                type="email"
                value={email.value}
                onChange={email.onChange}
            />
            
            <button onClick={search.reset}>Clear Search</button>
        </div>
    );
};

πŸ› οΈ Utility Hooks

These are the small, sharp tools you'll reach for again and again β€” the duct tape of a React codebase. None of them is complicated on its own, but each one hides a browser API or an easy-to-forget cleanup behind a clean, typed interface. Build them once, keep them in a hooks/ folder, and you'll stop rewriting the same addEventListener/removeEventListener pairs for the rest of your career. 🧰

useLocalStorage Hook

Persistent State with localStorage

function useLocalStorage<T>(
    key: string,
    initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
    // Get initial value from localStorage or use initialValue
    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;
        }
    });
    
    // Return a wrapped version of useState's setter function that
    // persists the new value to localStorage
    const setValue = (value: T | ((prev: T) => T)) => {
        try {
            // Allow value to be a function so we have same API as useState
            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];
}

// Usage
const Settings: React.FC = () => {
    const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
    const [language, setLanguage] = useLocalStorage('language', 'en');
    
    return (
        <div>
            <select value={theme} onChange={(e) => setTheme(e.target.value as 'light' | 'dark')}>
                <option value="light">Light</option>
                <option value="dark">Dark</option>
            </select>
            
            <p>Current theme: {theme}</p>
            <p>Refresh the page - theme persists!</p>
        </div>
    );
};

useDebounce Hook

Delay Value Updates

function useDebounce<T>(value: T, delay: number = 500): T {
    const [debouncedValue, setDebouncedValue] = useState<T>(value);
    
    useEffect(() => {
        // Set up timeout to update debounced value
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);
        
        // Clean up timeout if value changes before delay
        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);
    
    return debouncedValue;
}

// Usage: Search with debounce
const SearchComponent: React.FC = () => {
    const [searchTerm, setSearchTerm] = useState('');
    const debouncedSearchTerm = useDebounce(searchTerm, 500);
    const [results, setResults] = useState([]);
    
    // Effect only runs when debounced value changes
    useEffect(() => {
        if (debouncedSearchTerm) {
            fetch(`/api/search?q=${debouncedSearchTerm}`)
                .then(res => res.json())
                .then(setResults);
        }
    }, [debouncedSearchTerm]);
    
    return (
        <div>
            <input
                type="search"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                placeholder="Search..."
            />
            <p>Searching for: {debouncedSearchTerm}</p>
            <ul>
                {results.map((result: any) => (
                    <li key={result.id}>{result.name}</li>
                ))}
            </ul>
        </div>
    );
};

useToggle Hook

Boolean State Toggle

function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
    const [value, setValue] = useState(initialValue);
    
    const toggle = () => {
        setValue(prev => !prev);
    };
    
    return [value, toggle, setValue];
}

// Usage
const Modal: React.FC = () => {
    const [isOpen, toggleOpen, setIsOpen] = useToggle(false);
    
    return (
        <div>
            <button onClick={toggleOpen}>Toggle Modal</button>
            <button onClick={() => setIsOpen(true)}>Open Modal</button>
            <button onClick={() => setIsOpen(false)}>Close Modal</button>
            
            {isOpen && (
                <div className="modal">
                    <h2>Modal Content</h2>
                    <button onClick={toggleOpen}>Close</button>
                </div>
            )}
        </div>
    );
};

useWindowSize Hook

Track Window Dimensions

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

function useWindowSize(): WindowSize {
    const [windowSize, setWindowSize] = useState<WindowSize>({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        const handleResize = () => {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };
        
        window.addEventListener('resize', handleResize);
        
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, []);
    
    return windowSize;
}

// Usage
const ResponsiveComponent: React.FC = () => {
    const { width, height } = useWindowSize();
    
    return (
        <div>
            <p>Window size: {width} x {height}</p>
            {width < 768 ? (
                <MobileView />
            ) : (
                <DesktopView />
            )}
        </div>
    );
};

usePrevious Hook

Access Previous Value

// React 19 note: useRef now REQUIRES an initial argument,
// so pass `undefined` explicitly rather than calling useRef<T>().
function usePrevious<T>(value: T): T | undefined {
    const ref = useRef<T | undefined>(undefined);

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

    return ref.current;
}

// Usage
const Counter: React.FC = () => {
    const [count, setCount] = useState(0);
    const previousCount = usePrevious(count);
    
    return (
        <div>
            <p>Current: {count}</p>
            <p>Previous: {previousCount}</p>
            <button onClick={() => setCount(count + 1)}>Increment</button>
        </div>
    );
};

useOnClickOutside Hook

Detect Clicks Outside Element

// React 19 note: a ref from useRef<T>(null) is typed RefObject<T | null>,
// so accept the nullable form here.
function useOnClickOutside<T extends HTMLElement>(
    ref: React.RefObject<T | null>,
    handler: (event: MouseEvent | TouchEvent) => void
) {
    useEffect(() => {
        const listener = (event: MouseEvent | TouchEvent) => {
            // Do nothing if clicking ref's element or descendent elements
            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]);
}

// Usage
const Dropdown: React.FC = () => {
    const [isOpen, setIsOpen] = useState(false);
    const ref = useRef<HTMLDivElement>(null);
    
    useOnClickOutside(ref, () => setIsOpen(false));
    
    return (
        <div ref={ref}>
            <button onClick={() => setIsOpen(!isOpen)}>
                Toggle Dropdown
            </button>
            {isOpen && (
                <ul className="dropdown">
                    <li>Option 1</li>
                    <li>Option 2</li>
                    <li>Option 3</li>
                </ul>
            )}
        </div>
    );
};

useInterval Hook

Declarative setInterval

function useInterval(callback: () => void, delay: number | null) {
    const savedCallback = useRef(callback);
    
    // Remember the latest callback
    useEffect(() => {
        savedCallback.current = callback;
    }, [callback]);
    
    // Set up the interval
    useEffect(() => {
        if (delay === null) return;
        
        const tick = () => {
            savedCallback.current();
        };
        
        const id = setInterval(tick, delay);
        
        return () => clearInterval(id);
    }, [delay]);
}

// Usage
const Timer: React.FC = () => {
    const [seconds, setSeconds] = useState(0);
    const [isRunning, setIsRunning] = useState(true);
    
    useInterval(() => {
        setSeconds(seconds + 1);
    }, isRunning ? 1000 : null); // Pass null to pause
    
    return (
        <div>
            <p>Seconds: {seconds}</p>
            <button onClick={() => setIsRunning(!isRunning)}>
                {isRunning ? 'Pause' : 'Resume'}
            </button>
        </div>
    );
};

πŸ”— Hook Composition

The real power of custom hooks comes from composing them together. Let's learn how to build complex functionality by combining simple hooks!

Basic Composition

Using Multiple Hooks Together

// useUserProfile combines fetch and localStorage
function useUserProfile(userId: number) {
    // Use existing hooks
    const { data, isLoading, error, refetch } = useFetch<User>(
        `https://api.example.com/users/${userId}`
    );
    
    const [cachedUser, setCachedUser] = useLocalStorage<User | null>(
        `user-${userId}`,
        null
    );
    
    // Cache data when it loads
    useEffect(() => {
        if (data) {
            setCachedUser(data);
        }
    }, [data, setCachedUser]);
    
    // Return cached data while loading
    return {
        user: data || cachedUser,
        isLoading,
        error,
        refetch
    };
}

// Usage
const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
    const { user, isLoading, error, refetch } = useUserProfile(userId);
    
    if (isLoading && !user) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    
    return (
        <div>
            <h2>{user?.name}</h2>
            <button onClick={refetch}>Refresh</button>
        </div>
    );
};

Advanced Composition Example

useAuth Hook

interface User {
    id: number;
    email: string;
    name: string;
}

interface UseAuthResult {
    user: User | null;
    isLoading: boolean;
    isAuthenticated: boolean;
    login: (email: string, password: string) => Promise<void>;
    logout: () => void;
    register: (email: string, password: string, name: string) => Promise<void>;
}

function useAuth(): UseAuthResult {
    // Compose multiple hooks
    const [user, setUser] = useLocalStorage<User | null>('user', null);
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
    
    // Check if authenticated
    const isAuthenticated = user !== null;
    
    const login = async (email: string, password: string) => {
        try {
            setIsLoading(true);
            setError(null);
            
            const response = await fetch('/api/auth/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ email, password })
            });
            
            if (!response.ok) {
                throw new Error('Login failed');
            }
            
            const userData = await response.json();
            setUser(userData);
        } catch (err) {
            setError(err instanceof Error ? err : new Error('Login failed'));
            throw err;
        } finally {
            setIsLoading(false);
        }
    };
    
    const logout = () => {
        setUser(null);
    };
    
    const register = async (email: string, password: string, name: string) => {
        try {
            setIsLoading(true);
            setError(null);
            
            const response = await fetch('/api/auth/register', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ email, password, name })
            });
            
            if (!response.ok) {
                throw new Error('Registration failed');
            }
            
            const userData = await response.json();
            setUser(userData);
        } catch (err) {
            setError(err instanceof Error ? err : new Error('Registration failed'));
            throw err;
        } finally {
            setIsLoading(false);
        }
    };
    
    return {
        user,
        isLoading,
        isAuthenticated,
        login,
        logout,
        register
    };
}

// Usage
const LoginPage: React.FC = () => {
    const auth = useAuth();
    const form = useForm({ email: '', password: '' });
    
    const handleLogin = async (values: typeof form.values) => {
        try {
            await auth.login(values.email, values.password);
            // Redirect to dashboard
        } catch (err) {
            alert('Login failed');
        }
    };
    
    if (auth.isAuthenticated) {
        return <div>Welcome, {auth.user?.name}!</div>;
    }
    
    return (
        <form onSubmit={form.handleSubmit(handleLogin)}>
            <input
                type="email"
                name="email"
                value={form.values.email}
                onChange={form.handleChange}
                placeholder="Email"
            />
            <input
                type="password"
                name="password"
                value={form.values.password}
                onChange={form.handleChange}
                placeholder="Password"
            />
            <button type="submit" disabled={auth.isLoading}>
                {auth.isLoading ? 'Logging in...' : 'Login'}
            </button>
        </form>
    );
};

Hook Composition Pattern

graph TD A[useAuth Hook] --> B[useLocalStorage] A --> C[useState] A --> D[fetch API] E[useUserProfile Hook] --> F[useFetch] E --> G[useLocalStorage] E --> H[useEffect] I[useSearchWithDebounce] --> J[useDebounce] I --> K[useFetch] I --> L[useState] style A fill:#667eea,color:#fff style E fill:#667eea,color:#fff style I fill:#667eea,color:#fff

Building a Shopping Cart Hook

useShoppingCart - Complex Composition

interface CartItem {
    id: number;
    name: string;
    price: number;
    quantity: number;
}

interface UseShoppingCartResult {
    items: CartItem[];
    total: number;
    itemCount: number;
    addItem: (item: Omit<CartItem, 'quantity'>) => void;
    removeItem: (id: number) => void;
    updateQuantity: (id: number, quantity: number) => void;
    clear: () => void;
}

function useShoppingCart(): UseShoppingCartResult {
    // Compose with localStorage for persistence
    const [items, setItems] = useLocalStorage<CartItem[]>('cart', []);
    
    const addItem = (newItem: Omit<CartItem, 'quantity'>) => {
        setItems(prevItems => {
            const existingItem = prevItems.find(item => item.id === newItem.id);
            
            if (existingItem) {
                // Increase quantity if item exists
                return prevItems.map(item =>
                    item.id === newItem.id
                        ? { ...item, quantity: item.quantity + 1 }
                        : item
                );
            } else {
                // Add new item with quantity 1
                return [...prevItems, { ...newItem, quantity: 1 }];
            }
        });
    };
    
    const removeItem = (id: number) => {
        setItems(prevItems => prevItems.filter(item => item.id !== id));
    };
    
    const updateQuantity = (id: number, quantity: number) => {
        if (quantity <= 0) {
            removeItem(id);
            return;
        }
        
        setItems(prevItems =>
            prevItems.map(item =>
                item.id === id ? { ...item, quantity } : item
            )
        );
    };
    
    const clear = () => {
        setItems([]);
    };
    
    // Calculate totals (derived state)
    const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const itemCount = items.reduce((count, item) => count + item.quantity, 0);
    
    return {
        items,
        total,
        itemCount,
        addItem,
        removeItem,
        updateQuantity,
        clear
    };
}

// Usage
const ShoppingCart: React.FC = () => {
    const cart = useShoppingCart();
    
    return (
        <div>
            <h2>Shopping Cart ({cart.itemCount} items)</h2>
            
            {cart.items.length === 0 ? (
                <p>Your cart is empty</p>
            ) : (
                <>
                    <ul>
                        {cart.items.map(item => (
                            <li key={item.id}>
                                <span>{item.name}</span>
                                <span>${item.price}</span>
                                <input
                                    type="number"
                                    value={item.quantity}
                                    onChange={(e) => 
                                        cart.updateQuantity(item.id, parseInt(e.target.value))
                                    }
                                />
                                <button onClick={() => cart.removeItem(item.id)}>
                                    Remove
                                </button>
                            </li>
                        ))}
                    </ul>
                    
                    <div>
                        <h3>Total: ${cart.total.toFixed(2)}</h3>
                        <button onClick={cart.clear}>Clear Cart</button>
                    </div>
                </>
            )}
        </div>
    );
};

βœ… Composition Best Practices

  • Keep hooks focused: Each hook should do one thing well
  • Compose for complexity: Build complex hooks from simple ones
  • Return consistent shapes: Objects with named properties are clearer than arrays
  • Document dependencies: Make it clear which hooks are used
  • Avoid deep nesting: Extract nested logic into separate hooks

πŸ“˜ TypeScript Best Practices

Let's ensure our custom hooks are properly typed for maximum safety and developer experience!

Generic Hooks

Making Hooks Reusable with Generics

// βœ… Good: Generic hook
function useFetch<T>(url: string) {
    const [data, setData] = useState<T | null>(null);
    // ...
    return { data, isLoading, error };
}

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

// ❌ Bad: No generics
function useFetch(url: string) {
    const [data, setData] = useState(null); // data is 'any'
    // ...
    return { data, isLoading, error };
}

Return Type Patterns

Tuple vs Object Returns

// Pattern 1: Tuple (like useState)
function useToggle(initial: boolean): [boolean, () => void] {
    const [value, setValue] = useState(initial);
    const toggle = () => setValue(v => !v);
    return [value, toggle];
}

// Usage - order matters
const [isOpen, toggleOpen] = useToggle(false);

// Pattern 2: Object (more descriptive)
function useToggle(initial: boolean) {
    const [value, setValue] = useState(initial);
    const toggle = () => setValue(v => !v);
    return { value, toggle, setValue };
}

// Usage - names matter
const { value: isOpen, toggle: toggleOpen } = useToggle(false);

// Guidelines:
// - Use tuples for 2-3 simple values (like useState)
// - Use objects for 4+ values or complex APIs
// - Use objects when names provide clarity

Typing Hook Parameters

Parameter Types and Defaults

// Simple parameters
function useCounter(initialValue: number = 0) {
    // ...
}

// Options object (recommended for many parameters)
interface UseCounterOptions {
    min?: number;
    max?: number;
    step?: number;
}

function useCounter(
    initialValue: number = 0,
    options: UseCounterOptions = {}
) {
    const { min, max, step = 1 } = options;
    // ...
}

// Callback parameters
interface UseEffectOnceCallback {
    (): void | (() => void); // Can return cleanup function
}

function useEffectOnce(effect: UseEffectOnceCallback) {
    useEffect(effect, []);
}

Complex Return Types

Defining Return Type Interfaces

// Define clear return type interface
interface UseFetchResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
    // Implementation
    return { data, isLoading, error, refetch };
}

// Benefits:
// 1. Clear API documentation
// 2. Better autocomplete
// 3. Easier to maintain
// 4. Consistent return shape

Type Guards in Hooks

Narrowing Types

function useFetchUser(userId: number | null) {
    const [user, setUser] = useState<User | null>(null);
    
    useEffect(() => {
        // Type guard
        if (userId === null) return;
        
        // TypeScript knows userId is number here
        fetch(`/api/users/${userId}`)
            .then(res => res.json())
            .then(setUser);
    }, [userId]);
    
    return user;
}

// Discriminated unions
type FetchState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };

function useFetch<T>(url: string) {
    const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
    
    // TypeScript knows the shape based on status
    if (state.status === 'success') {
        console.log(state.data); // data exists here
    }
    
    return state;
}

Utility Types in Hooks

Using TypeScript Utility Types

// Partial - make all properties optional
interface User {
    id: number;
    name: string;
    email: string;
}

function useUpdateUser(userId: number) {
    const update = async (updates: Partial<User>) => {
        // Can update just name, or just email, etc.
        await fetch(`/api/users/${userId}`, {
            method: 'PATCH',
            body: JSON.stringify(updates)
        });
    };
    
    return { update };
}

// Pick - select specific properties
type UserProfile = Pick<User, 'name' | 'email'>;

function useUserProfile() {
    const [profile, setProfile] = useState<UserProfile | null>(null);
    return { profile, setProfile };
}

// Omit - exclude specific properties
type CreateUserData = Omit<User, 'id'>;

function useCreateUser() {
    const create = async (data: CreateUserData) => {
        // id will be generated by server
        await fetch('/api/users', {
            method: 'POST',
            body: JSON.stringify(data)
        });
    };
    
    return { create };
}

// Record - create object type
function useKeyValueStore<T>() {
    const [store, setStore] = useState<Record<string, T>>({});
    
    const set = (key: string, value: T) => {
        setStore(prev => ({ ...prev, [key]: value }));
    };
    
    return { store, set };
}

βœ… TypeScript Hook Checklist

  • ☐ Use generics for reusable hooks
  • ☐ Define clear return type interfaces
  • ☐ Type all parameters explicitly
  • ☐ Return tuples with as const so their types stay exact
  • ☐ Use utility types (Partial, Pick, Omit) appropriately
  • ☐ Provide default values for optional parameters
  • ☐ Reach for optional chaining (?.) and nullish coalescing (??) on optional options
  • ☐ Use discriminated unions for complex state
  • ☐ Export types alongside hooks
  • ☐ Document types with JSDoc comments

πŸ‹οΈ Hands-on Practice

Let's build some custom hooks to practice what you've learned!

πŸ‹οΈ Exercise 1: useAsync Hook

Create a hook that handles any async function with loading and error states.

Requirements:

  • Accept an async function as parameter
  • Track loading, error, and data states
  • Provide an execute function to run the async function
  • Handle errors properly
  • Type with TypeScript generics

Starter Code:

interface UseAsyncResult<T> {
    data: T | null;
    isLoading: boolean;
    error: Error | null;
    execute: (...args: any[]) => Promise<void>;
}

function useAsync<T>(asyncFunction: (...args: any[]) => Promise<T>): UseAsyncResult<T> {
    // Your code here!
}

// Usage example
const MyComponent: React.FC = () => {
    const fetchUser = async (id: number) => {
        const response = await fetch(`/api/users/${id}`);
        return response.json();
    };
    
    const { data, isLoading, error, execute } = useAsync(fetchUser);
    
    return (
        <div>
            <button onClick={() => execute(1)}>Load User</button>
            {isLoading && <div>Loading...</div>}
            {error && <div>Error: {error.message}</div>}
            {data && <div>User: {data.name}</div>}
        </div>
    );
};
πŸ’‘ Hint

Use useState for data, loading, and error. Create an execute function that wraps the async function in try-catch.

βœ… Solution
function useAsync<T>(
    asyncFunction: (...args: any[]) => Promise<T>
): UseAsyncResult<T> {
    const [data, setData] = useState<T | null>(null);
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);
    
    const execute = async (...args: any[]) => {
        try {
            setIsLoading(true);
            setError(null);
            
            const result = await asyncFunction(...args);
            setData(result);
        } catch (err) {
            setError(err instanceof Error ? err : new Error('An error occurred'));
        } finally {
            setIsLoading(false);
        }
    };
    
    return { data, isLoading, error, execute };
}

πŸ‹οΈ Exercise 2: useMediaQuery Hook

Create a hook that tracks whether a media query matches.

Requirements:

  • Accept a media query string (e.g., "(min-width: 768px)")
  • Return true/false based on whether query matches
  • Update when window resizes
  • Clean up event listener

Starter Code:

function useMediaQuery(query: string): boolean {
    // Your code here!
}

// Usage
const MyComponent: React.FC = () => {
    const isMobile = useMediaQuery('(max-width: 768px)');
    const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
    const isDesktop = useMediaQuery('(min-width: 1025px)');
    
    return (
        <div>
            {isMobile && <MobileView />}
            {isTablet && <TabletView />}
            {isDesktop && <DesktopView />}
        </div>
    );
};
βœ… Solution
function useMediaQuery(query: string): boolean {
    const [matches, setMatches] = useState(() => {
        return window.matchMedia(query).matches;
    });
    
    useEffect(() => {
        const mediaQuery = window.matchMedia(query);
        
        const handleChange = (e: MediaQueryListEvent) => {
            setMatches(e.matches);
        };
        
        // Modern browsers
        mediaQuery.addEventListener('change', handleChange);
        
        return () => {
            mediaQuery.removeEventListener('change', handleChange);
        };
    }, [query]);
    
    return matches;
}

πŸ‹οΈ Exercise 3: useToggle Hook

Create a simple hook that manages boolean state with an easy toggle β€” the workhorse behind modals, dropdowns, and "show more" panels.

Requirements:

  • Accept an optional initial value (default false)
  • Return the current value and a toggle function
  • Add convenience setTrue and setFalse functions
  • Type everything properly and return a tuple with as const
πŸ’‘ Hint

Wrap each returned function in useCallback so the references stay stable, and end with as const so the tuple keeps its exact types instead of widening.

βœ… Solution
function useToggle(initialValue: boolean = false) {
    const [value, setValue] = useState(initialValue);

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

    const setTrue = useCallback(() => setValue(true), []);
    const setFalse = useCallback(() => setValue(false), []);

    // `as const` keeps the tuple types exact: [boolean, () => void, ...]
    return [value, toggle, setTrue, setFalse] as const;
}

// Usage
const [isOpen, toggle, open, close] = useToggle(false);

return (
    <div>
        <button onClick={toggle}>Toggle</button>
        <button onClick={open}>Open</button>
        <button onClick={close}>Close</button>
        {isOpen && <p>Content is visible!</p>}
    </div>
);

πŸ‹οΈ Exercise 4: useArray Hook

Managing array state by hand is fiddly β€” every update needs a fresh array. Build a hook that hands you clean, immutable helpers so components never touch the raw array.

Requirements:

  • Accept an initial array and use generics for type safety
  • Return the array plus helpers: push, remove (by index), filter, update (by index), and clear
  • Always create a new array reference on each change
πŸ’‘ Hint

Use the functional form of the setter (setArray(arr => ...)) so each helper works from the latest array, and wrap them in useCallback so they don't change identity between renders.

βœ… Solution
interface UseArrayReturn<T> {
    array: T[];
    set: (newArray: T[]) => void;
    push: (item: T) => void;
    remove: (index: number) => void;
    filter: (callback: (item: T) => boolean) => void;
    update: (index: number, newItem: T) => void;
    clear: () => void;
}

function useArray<T>(initialValue: T[]): UseArrayReturn<T> {
    const [array, setArray] = useState<T[]>(initialValue);

    const push = useCallback((item: T) => {
        setArray(arr => [...arr, item]);
    }, []);

    const remove = useCallback((index: number) => {
        setArray(arr => arr.filter((_, i) => i !== index));
    }, []);

    const filter = useCallback((callback: (item: T) => boolean) => {
        setArray(arr => arr.filter(callback));
    }, []);

    const update = useCallback((index: number, newItem: T) => {
        setArray(arr => arr.map((item, i) => (i === index ? newItem : item)));
    }, []);

    const clear = useCallback(() => setArray([]), []);

    return { array, set: setArray, push, remove, filter, update, clear };
}

// Usage
interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

const TodoList: React.FC = () => {
    const { array: todos, push, remove, update } = useArray<Todo>([]);

    const toggleTodo = (index: number) => {
        const todo = todos[index];
        update(index, { ...todo, completed: !todo.completed });
    };

    return (
        <div>
            {todos.map((todo, index) => (
                <div key={todo.id}>
                    <input
                        type="checkbox"
                        checked={todo.completed}
                        onChange={() => toggleTodo(index)}
                    />
                    <span>{todo.text}</span>
                    <button onClick={() => remove(index)}>Delete</button>
                </div>
            ))}
        </div>
    );
};

πŸ‹οΈ Challenge: useUndoRedo Hook

Create a hook that provides undo/redo functionality for any state.

Requirements:

  • Track history of state changes
  • Provide undo and redo functions
  • Provide canUndo and canRedo boolean flags
  • Limit history size (e.g., last 10 states)
  • Type with generics

Bonus: Add a reset function and a history array to see all past states!

πŸ‹οΈ Challenge: usePagination Hook

Pagination shows up in almost every data-heavy app. Package the whole thing β€” page math, bounds checking, and the current slice of data β€” into one reusable hook.

Requirements:

  • Track current page, items per page, and total pages
  • Expose nextPage, previousPage, goToPage, and setItemsPerPage
  • Compute hasNextPage, hasPreviousPage, and the current data slice
  • Clamp the page number so it can never go out of bounds
  • Type it with generics so it paginates any array
πŸ’‘ Hint

Use Math.ceil(data.length / itemsPerPage) for the total, and clamp every page change with Math.max(1, Math.min(page, totalPages)) so navigation stays in range.

βœ… Solution
interface UsePaginationOptions {
    initialPage?: number;
    initialItemsPerPage?: number;
}

interface UsePaginationReturn<T> {
    currentPage: number;
    totalPages: number;
    itemsPerPage: number;
    hasNextPage: boolean;
    hasPreviousPage: boolean;
    currentData: T[];
    nextPage: () => void;
    previousPage: () => void;
    goToPage: (page: number) => void;
    setItemsPerPage: (items: number) => void;
}

function usePagination<T>(
    data: T[],
    options: UsePaginationOptions = {}
): UsePaginationReturn<T> {
    const { initialPage = 1, initialItemsPerPage = 10 } = options;

    const [currentPage, setCurrentPage] = useState(initialPage);
    const [itemsPerPage, setItemsPerPageState] = useState(initialItemsPerPage);

    const totalPages = Math.ceil(data.length / itemsPerPage);

    // Keep the current page inside the valid range if data shrinks
    useEffect(() => {
        if (currentPage > totalPages && totalPages > 0) {
            setCurrentPage(totalPages);
        }
    }, [currentPage, totalPages]);

    const startIndex = (currentPage - 1) * itemsPerPage;
    const currentData = data.slice(startIndex, startIndex + itemsPerPage);

    const hasNextPage = currentPage < totalPages;
    const hasPreviousPage = currentPage > 1;

    const nextPage = useCallback(() => {
        setCurrentPage(page => Math.min(page + 1, totalPages));
    }, [totalPages]);

    const previousPage = useCallback(() => {
        setCurrentPage(page => Math.max(page - 1, 1));
    }, []);

    const goToPage = useCallback((page: number) => {
        setCurrentPage(Math.max(1, Math.min(page, totalPages)));
    }, [totalPages]);

    const setItemsPerPage = useCallback((items: number) => {
        setItemsPerPageState(items);
        setCurrentPage(1); // reset to first page when page size changes
    }, []);

    return {
        currentPage, totalPages, itemsPerPage,
        hasNextPage, hasPreviousPage, currentData,
        nextPage, previousPage, goToPage, setItemsPerPage,
    };
}

// Usage
const ProductList: React.FC<{ products: Product[] }> = ({ products }) => {
    const {
        currentData, currentPage, totalPages,
        hasNextPage, hasPreviousPage, nextPage, previousPage,
    } = usePagination(products, { initialItemsPerPage: 20 });

    return (
        <div>
            {currentData.map(product => (
                <p key={product.id}>{product.name}</p>
            ))}

            <button onClick={previousPage} disabled={!hasPreviousPage}>Previous</button>
            <span>Page {currentPage} of {totalPages}</span>
            <button onClick={nextPage} disabled={!hasNextPage}>Next</button>
        </div>
    );
};

🌟 Best Practices

βœ… Do's

  • Always prefix custom hooks with "use" - This is required for React to recognize them as hooks and enforce the Rules of Hooks. useFetch, not fetchData.
  • Keep hooks focused and single-purpose - A hook should do one thing well. useLocalStorage manages local storage, useFetch handles fetching. Don't create mega-hooks that do everything.
  • Use TypeScript generics - Make your hooks reusable with different data types by using generics: function useFetch<T>(url: string): T
  • Memoize functions with useCallback - Wrap returned functions in useCallback to prevent unnecessary re-renders in components that use your hook.
  • Handle cleanup properly - Always clean up subscriptions, timers, and event listeners in useEffect return functions to prevent memory leaks.
  • Provide clear return values - Use descriptive names and consistent return patterns. Return objects for many values, tuples for 2-3 values.
  • Document your hooks - Add JSDoc comments explaining parameters, return values, and usage examples. Other developers (including future you!) will thank you.
  • Test your hooks - Use React Testing Library's renderHook to test custom hooks in isolation.

❌ Don'ts

  • Don't call hooks conditionally - Never put hooks inside if statements, loops, or nested functions. React relies on hook call order to track state correctly.
  • Don't forget dependencies - Always include all values used inside useEffect or useCallback in the dependency array. Use ESLint's exhaustive-deps rule!
  • Don't create overly complex hooks - If your hook has 10+ parameters or returns 15+ values, it's doing too much. Break it into smaller hooks.
  • Don't ignore cleanup - Forgetting to clean up event listeners, intervals, or subscriptions leads to memory leaks and bugs.
  • Don't use hooks in regular functions - Custom hooks can only be called from React components or other custom hooks, never from regular JavaScript functions.
  • Don't mutate state directly - Always create new objects/arrays when updating state: setState([...arr, newItem]), not arr.push(newItem).
  • Don't return inconsistent types - If your hook returns an object, always return an object with the same keys, even if some values are null/undefined.

πŸ’‘ Pro Tips

  • Create a hooks directory - Keep all custom hooks in a hooks/ or src/hooks/ directory for easy organization and imports.
  • Start with common patterns - Build a library of reusable hooks for common needs: useFetch, useLocalStorage, useDebounce, useToggle.
  • Compose hooks together - Build complex hooks by combining simpler ones. For example, useForm might use useLocalStorage and useValidation internally.
  • Use discriminated unions for complex state - Instead of separate loading/error/data states, use a single state with a discriminated union for type safety.
  • Provide sensible defaults - Make hooks easy to use with good default values for optional parameters.
  • Consider returning both the state and a reset function - Many hooks benefit from a way to reset to initial state: const [value, setValue, reset] = useInput().
  • Use the react-hooks ESLint plugin - It catches mistakes like missing dependencies and conditional hook calls automatically.
  • Learn from the community - Check out libraries like react-use, usehooks-ts, and ahooks for inspiration and patterns.

βœ… Custom Hook Recipe Template

/**
 * Hook description: What does this hook do?
 * 
 * @param param1 - Description of first parameter
 * @param options - Optional configuration
 * @returns Description of return value
 * 
 * @example
 * const { data, loading } = useYourHook('example');
 */
function useYourHook<T>(
    param1: string,
    options?: YourHookOptions
): YourHookReturn<T> {
    // 1. State declarations
    const [state, setState] = useState<T | null>(null);
    const [loading, setLoading] = useState(false);
    
    // 2. Helper functions (memoized)
    const doSomething = useCallback(() => {
        // Implementation
    }, [/* dependencies */]);
    
    // 3. Side effects
    useEffect(() => {
        // Effect logic
        
        return () => {
            // Cleanup
        };
    }, [/* dependencies */]);
    
    // 4. Return value
    return {
        state,
        loading,
        doSomething,
    };
}

⚠️ Common Pitfalls

  • Stale closures: Functions inside useEffect or useCallback capture old state values. Always include them in dependencies or use functional updates.
  • Infinite loops: Forgetting dependencies or creating new objects/arrays in the dependency array causes infinite re-renders.
  • Memory leaks: Not cleaning up subscriptions, timers, or event listeners in the effect cleanup function.
  • Race conditions: Multiple async operations completing out of order. Use abort controllers or track the latest request.

πŸ“š Summary

πŸŽ‰ Key Takeaways

  • Custom hooks extract reusable stateful logic - They let you share logic between components without prop drilling or wrapper components.
  • Always start with "use" - This naming convention is required and tells React to enforce the Rules of Hooks.
  • Hooks compose beautifully - Build complex hooks by combining simpler ones, creating powerful abstractions.
  • TypeScript makes hooks better - Use generics for flexibility, proper types for safety, and discriminated unions for complex state.
  • Follow the Rules of Hooks - Only call hooks at the top level and only from React functions.
  • Clean up effects properly - Prevent memory leaks by cleaning up subscriptions, listeners, and timers.
  • Return consistent, well-typed values - Use tuples for 2-3 values, objects for more complex returns.
  • Common hook patterns solve common problems - useFetch, useLocalStorage, useDebounce, useToggle are building blocks for most apps.
  • Test your hooks - Use React Testing Library's renderHook to test hooks in isolation.
  • Custom hooks are a superpower - They're one of React's most powerful features for creating clean, reusable code!

πŸ“š Additional Resources

πŸš€ What's Next?

You've mastered custom hooks! You can now extract complex logic into reusable functions, build your own hook library, and create cleaner, more maintainable React applications.

In Module 5: Advanced Hooks and Patterns, we'll explore:

  • useReducer for complex state logic
  • useContext for sharing state across the component tree
  • useMemo and useCallback for performance optimization
  • useRef for accessing DOM elements and storing mutable values
  • Advanced patterns like compound components and render props

🎯 Quick Quiz

Question 1: What is the required naming convention for custom hooks?

Question 2: When should you use a custom hook?

Question 3: What should a custom hook that manages pagination return?

Question 4: How should you handle cleanup in custom hooks?

Question 5: What's the best way to type a generic custom hook in TypeScript?

πŸŽ‰ Congratulations!

You've completed Lesson 4.3: Custom Hooks! You now have the power to create reusable logic that makes your React applications cleaner, more maintainable, and more powerful.

Custom hooks are one of React's most elegant features. Use them wisely! 🎣