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?

Custom hooks are JavaScript functions that use React's built-in hooks. They let you extract component logic into reusable functions.

πŸ“– 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.

🎬 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

Custom hooks must follow the same rules as built-in hooks. These rules are enforced by ESLint and are crucial for hooks to work correctly.

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

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! πŸ’₯
}

ESLint Plugin

Automatic Rule Enforcement

# Install ESLint plugin for hooks
npm install eslint-plugin-react-hooks --save-dev
// .eslintrc.json
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

This plugin will catch hook violations automatically!

πŸ”’ 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

Let's build a simple custom hook step-by-step to understand the 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

One of the most common uses for custom hooks is abstracting data fetching logic. Let's build progressively better versions!

πŸ”„ 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

Form handling is repetitiveβ€”let's create hooks to make it easier!

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

Let's build some useful utility hooks that solve common problems!

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

function usePrevious<T>(value: T): T | undefined {
    const ref = useRef<T>();
    
    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

function useOnClickOutside<T extends HTMLElement>(
    ref: React.RefObject<T>,
    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
  • ☐ Use utility types (Partial, Pick, Omit) appropriately
  • ☐ Provide default values for optional parameters
  • ☐ 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;
}

πŸ‹οΈ 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!

πŸ“˜ TypeScript Best Practices for Custom Hooks

TypeScript makes custom hooks even more powerful by adding type safety. Let's explore best practices for typing your custom hooks!

1. Generic Hooks for Flexibility

Use generics to make your hooks work with any type of data:

// βœ… Good: Generic hook that works with any type
function useLocalStorage(key: string, initialValue: T) {
    const [storedValue, setStoredValue] = useState(() => {
        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;
}

// Usage with different types
const [name, setName] = useLocalStorage('name', 'Guest');
const [age, setAge] = useLocalStorage('age', 0);
const [settings, setSettings] = useLocalStorage('settings', defaultSettings);

2. Proper Return Types

Be explicit about what your hooks return:

// ❌ Bad: Return type is inferred as (string | Dispatch>)[]
function useInput(initialValue: string) {
    const [value, setValue] = useState(initialValue);
    const reset = () => setValue(initialValue);
    return [value, setValue, reset];
}

// βœ… Good: Explicit return type with const assertion
function useInput(initialValue: string) {
    const [value, setValue] = useState(initialValue);
    const reset = () => setValue(initialValue);
    return [value, setValue, reset] as const;
}

// βœ… Also Good: Return an object for better clarity
interface UseInputReturn {
    value: string;
    setValue: Dispatch>;
    reset: () => void;
}

function useInput(initialValue: string): UseInputReturn {
    const [value, setValue] = useState(initialValue);
    const reset = () => setValue(initialValue);
    return { value, setValue, reset };
}

πŸ’‘ Tuple vs Object Returns

Use tuples (arrays) when:

  • Returning 2-3 simple values (like useState)
  • Users might want to rename the values

Use objects when:

  • Returning many values (4+)
  • Values have clear semantic names
  • You want to add more return values later without breaking code

3. Type Optional Parameters

interface UseFetchOptions {
    method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
    headers?: Record;
    body?: any;
    immediate?: boolean; // Fetch immediately or wait for manual trigger
}

function useFetch(url: string, options?: UseFetchOptions) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(options?.immediate ?? true);
    const [error, setError] = useState(null);

    const execute = useCallback(async () => {
        setLoading(true);
        setError(null);
        
        try {
            const response = await fetch(url, {
                method: options?.method ?? 'GET',
                headers: options?.headers,
                body: options?.body ? JSON.stringify(options.body) : undefined,
            });
            
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            
            const result = await response.json();
            setData(result);
        } catch (err) {
            setError(err as Error);
        } finally {
            setLoading(false);
        }
    }, [url, options]);

    useEffect(() => {
        if (options?.immediate ?? true) {
            execute();
        }
    }, [execute, options?.immediate]);

    return { data, loading, error, refetch: execute };
}

4. Type Function Parameters

// βœ… Type callback functions properly
interface UseAsyncOptions {
    onSuccess?: (data: T) => void;
    onError?: (error: Error) => void;
    initialData?: T;
}

function useAsync(
    asyncFunction: () => Promise,
    options?: UseAsyncOptions
) {
    const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
    const [data, setData] = useState(options?.initialData);
    const [error, setError] = useState(null);

    const execute = useCallback(async () => {
        setStatus('loading');
        setData(undefined);
        setError(null);

        try {
            const result = await asyncFunction();
            setData(result);
            setStatus('success');
            options?.onSuccess?.(result);
        } catch (err) {
            setError(err as Error);
            setStatus('error');
            options?.onError?.(err as Error);
        }
    }, [asyncFunction, options]);

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

5. Discriminated Unions for Complex State

// βœ… Use discriminated unions for complex async state
type AsyncState =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: Error };

function useAsyncState(asyncFn: () => Promise) {
    const [state, setState] = useState>({ status: 'idle' });

    const execute = useCallback(async () => {
        setState({ status: 'loading' });
        
        try {
            const data = await asyncFn();
            setState({ status: 'success', data });
        } catch (error) {
            setState({ status: 'error', error: error as Error });
        }
    }, [asyncFn]);

    return { state, execute };
}

// Usage - TypeScript knows which properties are available!
const { state, execute } = useAsyncState(() => fetchUser(id));

if (state.status === 'success') {
    console.log(state.data.name); // TypeScript knows 'data' exists here
}

if (state.status === 'error') {
    console.log(state.error.message); // TypeScript knows 'error' exists here
}

βœ… TypeScript Hook Checklist

  • βœ… Use generics for flexible, reusable hooks
  • βœ… Use as const for tuple returns
  • βœ… Create interfaces for complex options
  • βœ… Type all parameters and return values
  • βœ… Use discriminated unions for complex state
  • βœ… Use optional chaining for optional parameters
  • βœ… Document your hooks with JSDoc comments

πŸ‹οΈ Hands-on Practice

Time to build your own custom hooks! These exercises will solidify your understanding.

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

Create a simple hook that manages boolean state with toggle functionality.

Requirements:

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

Think about what useState returns and how you can enhance it with additional functions. Use as const for the return tuple!

βœ… 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);
    }, []);
    
    return [value, toggle, setTrue, setFalse] as const;
}

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

return (
    
{isOpen &&

Content is visible!

}
);

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

Create a hook that provides helpful methods for managing array state.

Requirements:

  • Accept an initial array
  • Return the array and helper methods:
  • push(item) - add to end
  • remove(index) - remove by index
  • filter(callback) - filter items
  • update(index, newItem) - update specific item
  • clear() - empty the array
  • Use generics for type safety
πŸ’‘ Hint

Use useCallback for each helper function to prevent unnecessary re-renders. Remember to spread the array to create new references!

βœ… Solution
interface UseArrayReturn {
    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(initialValue: T[]): UseArrayReturn {
    const [array, setArray] = useState(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([]);

    const addTodo = (text: string) => {
        push({ id: Date.now(), text, completed: false });
    };

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

    return (
        
{todos.map((todo, index) => (
toggleTodo(index)} /> {todo.text}
))}
); };

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

Build a comprehensive async operation hook with status management.

Requirements:

  • Manage loading, success, and error states
  • Accept an async function to execute
  • Provide execute function to manually trigger
  • Return status, data, error, and execute
  • Support optional immediate execution
  • Use proper TypeScript generics
πŸ’‘ Hint

Consider using a status enum or union type. Don't forget to handle cleanup to prevent state updates on unmounted components!

βœ… Solution
type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';

interface UseAsyncReturn {
    execute: () => Promise;
    status: AsyncStatus;
    data: T | null;
    error: Error | null;
    isLoading: boolean;
    isSuccess: boolean;
    isError: boolean;
}

function useAsync(
    asyncFunction: () => Promise,
    immediate: boolean = false
): UseAsyncReturn {
    const [status, setStatus] = useState('idle');
    const [data, setData] = useState(null);
    const [error, setError] = useState(null);

    const execute = useCallback(async () => {
        setStatus('loading');
        setData(null);
        setError(null);

        try {
            const response = await asyncFunction();
            setData(response);
            setStatus('success');
        } catch (err) {
            setError(err as Error);
            setStatus('error');
        }
    }, [asyncFunction]);

    useEffect(() => {
        if (immediate) {
            execute();
        }
    }, [execute, immediate]);

    return {
        execute,
        status,
        data,
        error,
        isLoading: status === 'loading',
        isSuccess: status === 'success',
        isError: status === 'error',
    };
}

// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
    const fetchUser = useCallback(
        () => fetch(`/api/users/${userId}`).then(res => res.json()),
        [userId]
    );

    const { data: user, isLoading, isError, error, execute } = useAsync(
        fetchUser,
        true // Fetch immediately
    );

    if (isLoading) return 
Loading...
; if (isError) return
Error: {error?.message}
; if (!user) return null; return (

{user.name}

{user.email}

); };

πŸ‹οΈ Challenge: usePagination Hook

Create a comprehensive pagination hook for managing paginated data.

Requirements:

  • Track current page, total pages, items per page
  • Provide functions: nextPage, previousPage, goToPage, setItemsPerPage
  • Calculate: hasNextPage, hasPreviousPage, startIndex, endIndex
  • Accept total items count
  • Return current page data slice
  • Prevent going beyond page bounds
πŸ’‘ Hint

Use Math.ceil(totalItems / itemsPerPage) to calculate total pages. Remember to clamp page numbers between 1 and totalPages!

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

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

function usePagination(
    data: T[],
    options: UsePaginationOptions = {}
): UsePaginationReturn {
    const { initialPage = 1, initialItemsPerPage = 10 } = options;
    
    const [currentPage, setCurrentPage] = useState(initialPage);
    const [itemsPerPage, setItemsPerPageState] = useState(initialItemsPerPage);

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

    // Ensure current page is within bounds
    useEffect(() => {
        if (currentPage > totalPages && totalPages > 0) {
            setCurrentPage(totalPages);
        }
    }, [currentPage, totalPages]);

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

    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 changing items per page
    }, []);

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

// Usage
interface Product {
    id: number;
    name: string;
    price: number;
}

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

    return (
        
{currentData.map(product => (

{product.name}

${product.price}

))}
Page {currentPage} of {totalPages}
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( ))}
); };

🌟 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(
    param1: string,
    options?: YourHookOptions
): YourHookReturn {
    // 1. State declarations
    const [state, setState] = useState(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! 🎣