π£ 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:
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!
π¨ 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:
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 constfor 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, notfetchData. - Keep hooks focused and single-purpose - A hook should do one thing well.
useLocalStoragemanages local storage,useFetchhandles 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
useCallbackto prevent unnecessary re-renders in components that use your hook. - Handle cleanup properly - Always clean up subscriptions, timers, and event listeners in
useEffectreturn 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
renderHookto 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
useEffectoruseCallbackin 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]), notarr.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/orsrc/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,
useFormmight useuseLocalStorageanduseValidationinternally. - 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, andahooksfor 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
useEffectoruseCallbackcapture 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
- React Docs: Reusing Logic with Custom Hooks
- React TypeScript Cheatsheet: Hooks
- useHooks - Collection of Custom Hooks
- react-use - Essential React Hooks Library
- usehooks-ts - TypeScript Custom Hooks
- Testing Library: Testing Custom Hooks
π 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! π£