Building Custom React Hooks — Reusable Logic Patterns
In this tutorial, you'll learn about Building Custom React Hooks. We cover key concepts, practical examples, and best practices.
What You'll Learn
Build custom React hooks that encapsulate reusable logic — data fetching with loading and error states, form handling with validation, browser API wrappers, and composition patterns with TypeScript generics.
Why It Matters
Custom hooks are the primary way to share non-visual logic between React components. They eliminate copy-paste code, make testing easier, and enforce consistent behavior across your application. Without custom hooks, the same fetch-logic, form-handling, or event-binding code gets scattered across dozens of components.
Real-World Use
A useDebouncedSearch hook that combines debounce logic with API fetching, used by search bars across the entire application. A useAuth hook that wraps authentication state, token refresh, and logout — consumed by any component that needs user identity.
Learning Path
flowchart LR
A[React Basics] --> B[Hooks Deep Dive]
B --> C[Custom Hooks]
C --> D[Design Patterns]
D --> E[Testing React]
E --> F[Final Project]
C -->|You are here| C
Custom Hook Rules
A custom hook is a JavaScript function that:
- Starts with the prefix
use - Calls other hooks (useState, useEffect, useRef, etc.)
- Returns values, functions, or both
import { useState, useEffect } from "react";
function useWindowWidth(): number {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
function ResponsiveSidebar() {
const width = useWindowWidth();
return (
<aside style={{ width: width < 768 ? "100%" : 240 }}>
{width < 768 ? "Collapsed" : "Expanded"} sidebar
</aside>
);
}
Expected output: The sidebar displays "Expanded sidebar" at 768px+ width and "Collapsed sidebar" below 768px. The hook encapsulates the resize listener logic. Any component can reuse useWindowWidth without duplicating the event binding and cleanup.
TypeScript Generics in Custom Hooks
Using generics makes hooks flexible and type-safe across different data types.
import { useState, useEffect, useCallback } from "react";
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T = unknown>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const refetch = useCallback(() => {
setRefetchTrigger((c) => c + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: T = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [url, refetchTrigger]);
return { data, loading, error, refetch };
}
// Usage with type safety
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data: user, loading, error, refetch } = useFetch<User>(
`/api/users/${userId}`
);
if (loading) return <Spinner />;
if (error) return <ErrorDisplay message={error} onRetry={refetch} />;
return <h1>{user?.name}</h1>;
}
Expected output: The useFetch<User> hook returns typed data. user?.name is autocompleted in the IDE. Error states show a retry button that calls refetch. The generic <T> pattern means the same hook works for users, products, orders — any API resource.
Composing Hooks Together
Complex hooks are built by combining simpler hooks. This composition pattern keeps each hook focused on one responsibility.
import { useState, useEffect, useRef } from "react";
// Hook 1: Debounce a value
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Hook 2: AbortController for fetch cancellation
function useAbortController(): [AbortController, () => void] {
const controllerRef = useRef(new AbortController());
const renew = () => {
controllerRef.current.abort();
controllerRef.current = new AbortController();
};
useEffect(() => {
return () => controllerRef.current.abort();
}, []);
return [controllerRef.current, renew];
}
// Composed hook: debounced search with cancellation
function useDebouncedSearch<T>(query: string): FetchState<T> {
const debouncedQuery = useDebounce(query, 300);
const [abortController, renewController] = useAbortController();
const { data, loading, error } = useFetch<T>(
`/api/search?q=${debouncedQuery}`,
abortController.signal
);
return { data, loading, error, refetch: renewController };
}
Expected output: When the user types, the API call is debounced by 300ms. Previous requests are cancelled via AbortController when a new character is entered. The composition keeps useDebounce and useAbortController independently testable and reusable.
useLocalStorage — Persisting State
import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T) => {
setStoredValue(value);
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
console.error(`Failed to save ${key} to localStorage`);
}
},
[key]
);
useEffect(() => {
function handleStorageChange(e: StorageEvent) {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue) as T);
}
}
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [key]);
return [storedValue, setValue];
}
function SettingsPage() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
return (
<div className={theme}>
<select value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Expected output: Theme and font size persist across page refreshes. Opening the same page in another tab and changing the theme syncs automatically because the storage event listener updates the state.
useForm — Form State Management
import { useState, useCallback } from "react";
type ValidationRules<T> = {
[K in keyof T]?: {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
message?: string;
};
};
type Errors<T> = Partial<Record<keyof T, string>>;
function useForm<T extends Record<string, unknown>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Errors<T>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const handleChange = useCallback((name: keyof T, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
const handleBlur = useCallback((name: keyof T) => {
setTouched((prev) => ({ ...prev, [name]: true }));
}, []);
const validate = useCallback(
(rules: ValidationRules<T>): boolean => {
const newErrors: Errors<T> = {};
for (const [field, rule] of Object.entries(rules)) {
const value = values[field as keyof T] as string;
if (rule?.required && !value) {
newErrors[field as keyof T] = `${field} is required`;
} else if (rule?.minLength && typeof value === "string" && value.length < rule.minLength) {
newErrors[field as keyof T] = `Minimum ${rule.minLength} characters`;
} else if (rule?.pattern && typeof value === "string" && !rule.pattern.test(value)) {
newErrors[field as keyof T] = rule.message || "Invalid format";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
},
[values]
);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
return { values, errors, touched, handleChange, handleBlur, validate, reset, setValues };
}
Expected output: A login form using useForm tracks values, validation errors, and touched state. The validate function returns false with populated errors when fields are invalid. Calling reset clears everything back to initial values.
Common Mistakes
1. Not Using the use Prefix
React's ESLint plugin relies on the use prefix to enforce the rules of hooks. Skipping it disables linting and risks bugs.
2. Returning JSX from a Hook
Hooks should return data and functions, not JSX. If a hook needs to render UI, split it into a component that uses the hook internally.
3. Over-Abstracting Prematurely
Not every piece of logic needs to be a custom hook. Start with inline code, then extract when you see the same pattern three or more times.
Practice Questions
Challenge
Build a useIntersectionObserver hook that detects when an element enters the viewport. It should accept an options object (threshold, rootMargin) and return a ref to attach to the target element plus an isVisible boolean. Use it to implement lazy loading of images.
Real-World Task
Refactor a component that duplicates data-fetching logic (loading, error, retry) across three different API endpoints into a single useApiResource hook. The hook should accept a URL and return data, loading, error, and refetch. Each component should use the hook with its specific endpoint and TypeScript type.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where custom hooks keep thousands of components consistent and testable.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro