React Custom Hooks Pattern: Building Reusable State Logic
Custom hooks are how React developers share stateful logic without class components, render props, or HOCs. As your app grows, patterns emerge—authentication, data fetching, form handling—that should be extracted into reusable hooks. The challenge is designing hooks that are flexible, composable, and don't leak internal complexity.
Over the course of building several React apps and libraries, I've learned which patterns scale and which ones hit walls as requirements change. This article covers the battle-tested approaches.
The Basic Pattern: Extract and Compose
The first rule is to recognize when logic repeats and extract it. Here's a before-and-after example. Before:
function UserProfile() {
const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
let isMounted = true;
setLoading(true);
fetch('/api/user')
.then((r) => r.json())
.then((data) => {
if (isMounted) setUser(data);
})
.catch((err) => {
if (isMounted) setError(err);
})
.finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, []);
// Component JSX...
}
After extracting into a custom hook:
function useFetch<T>(url: string): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
let isMounted = true;
setLoading(true);
fetch(url)
.then((r) => r.json())
.then((result) => {
if (isMounted) setData(result);
})
.catch((err) => {
if (isMounted) setError(err);
})
.finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
function UserProfile() {
const { data: user, loading, error } = useFetch<User>('/api/user');
// Component is now much cleaner
}
The extracted hook is generic (useFetch<T>), dependency-safe (depends on url), and handles mounting cleanly. This is the foundation.
Hooks Composition: Combining Multiple Hooks
As you build more hooks, you'll need to compose them. For example, a hook that fetches data and manages it in a local cache:
interface CacheEntry<T> {
data: T;
timestamp: number;
}
function useFetchWithCache<T>(
url: string,
cacheDurationMs: number = 60000
): {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
} {
const cacheRef = React.useRef<CacheEntry<T> | null>(null);
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
const refetch = React.useCallback((): void => {
let isMounted = true;
setLoading(true);
const isCacheValid = cacheRef.current && Date.now() - cacheRef.current.timestamp < cacheDurationMs;
if (isCacheValid) {
setData(cacheRef.current.data);
setLoading(false);
return;
}
fetch(url)
.then((r) => r.json())
.then((result: T) => {
if (isMounted) {
cacheRef.current = { data: result, timestamp: Date.now() };
setData(result);
setError(null);
}
})
.catch((err) => {
if (isMounted) {
setError(err instanceof Error ? err : new Error(String(err)));
}
})
.finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, [url, cacheDurationMs]);
React.useEffect(() => {
refetch();
}, [refetch]);
return { data, loading, error, refetch };
}
This hook composes useRef, useState, useCallback, and useEffect. It's more complex, but solves a real problem (preventing redundant fetches). Composition allows hooks to build on each other.
Authentication Pattern
A common need is a hook that manages auth state (login, logout, user):
interface AuthUser {
id: string;
email: string;
roles: string[];
}
interface UseAuthReturn {
user: AuthUser | null;
isLoading: boolean;
error: Error | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAuthenticated: boolean;
}
function useAuth(): UseAuthReturn {
const [user, setUser] = React.useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
// Restore session on mount
React.useEffect(() => {
const restoreSession = async () => {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setIsLoading(false);
}
};
restoreSession();
}, []);
const login = React.useCallback(async (email: string, password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
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) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
throw error;
} finally {
setIsLoading(false);
}
}, []);
const logout = React.useCallback(async (): Promise<void> => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
setUser(null);
}
}, []);
return {
user,
isLoading,
error,
login,
logout,
isAuthenticated: user !== null,
};
}
// Usage in a protected component
function Dashboard() {
const { user, isLoading, isAuthenticated, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) return <div>Not logged in</div>;
return (
<div>
<p>Welcome, {user?.email}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
This hook manages login, logout, and session restoration. It's self-contained and exposes a clean API.
Form Handling Pattern
A complex but valuable pattern is a form hook that handles validation, submission, and field changes:
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
}
interface UseFormReturn<T> extends FormState<T> {
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
setFieldTouched: (field: keyof T) => void;
handleChange: (field: keyof T) => (e: React.ChangeEvent<HTMLInputElement>) => void;
handleSubmit: (e: React.FormEvent) => Promise<void>;
reset: () => void;
}
function useForm<T extends Record<string, any>>(
initialValues: T,
onSubmit: (values: T) => Promise<void>,
validate?: (values: T) => Partial<Record<keyof T, string>>
): UseFormReturn<T> {
const [state, setState] = React.useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
});
const setFieldValue = React.useCallback(
<K extends keyof T>(field: K, value: T[K]): void => {
setState((prev) => ({
...prev,
values: { ...prev.values, [field]: value },
}));
},
[]
);
const setFieldTouched = React.useCallback((field: keyof T): void => {
setState((prev) => ({
...prev,
touched: { ...prev.touched, [field]: true },
}));
}, []);
const handleChange = React.useCallback(
(field: keyof T) => (e: React.ChangeEvent<HTMLInputElement>): void => {
setFieldValue(field, e.currentTarget.value as any);
},
[setFieldValue]
);
const handleSubmit = React.useCallback(
async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
setState((prev) => ({ ...prev, isSubmitting: true }));
try {
if (validate) {
const errors = validate(state.values);
if (Object.keys(errors).length > 0) {
setState((prev) => ({ ...prev, errors, isSubmitting: false }));
return;
}
}
await onSubmit(state.values);
setState((prev) => ({
...prev,
errors: {},
isSubmitting: false,
}));
} catch (err) {
setState((prev) => ({
...prev,
errors: { submit: (err instanceof Error ? err.message : String(err)) } as any,
isSubmitting: false,
}));
}
},
[state.values, validate, onSubmit]
);
const reset = React.useCallback((): void => {
setState({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
});
}, [initialValues]);
return {
...state,
setFieldValue,
setFieldTouched,
handleChange,
handleSubmit,
reset,
};
}
// Usage
interface LoginForm {
email: string;
password: string;
}
function LoginForm() {
const form = useForm<LoginForm>(
{ email: '', password: '' },
async (values) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(values),
});
},
(values) => {
const errors: Partial<Record<keyof LoginForm, string>> = {};
if (!values.email.includes('@')) errors.email = 'Invalid email';
if (values.password.length < 8) errors.password = 'Too short';
return errors;
}
);
return (
<form onSubmit={form.handleSubmit}>
<input
type="email"
value={form.values.email}
onChange={form.handleChange('email')}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={form.handleChange('password')}
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
This hook is larger but solves a major problem: managing form state, validation, and submission in a type-safe way.
Key Takeaways
- Extract repeated logic into custom hooks early. A few extra lines of hook code saves much larger codebases.
- Compose hooks by calling other hooks. More powerful hooks build on simpler ones.
- Design hooks with clean return objects—minimize what callers need to know about internals.
- Use
useCallbackanduseMemoin hooks to ensure stable function references and avoid stale closures. - Always manage component mounting state (
isMounted) in hooks withuseEffectcleanup. - Type hook return objects explicitly so callers know exactly what properties and methods are available.
Frequently Asked Questions
How do I decide when to extract a custom hook?
If the same logic appears in 2+ components, or if a component has complex side effects, extract it. A rule of thumb: if useEffect logic is more than 20 lines, consider a hook.
Can a custom hook call another custom hook?
Yes. Hooks can call other hooks. This composition is powerful and encouraged.
Should custom hooks handle errors globally or return them?
Return errors in the hook's return object. Global error boundaries are for unexpected crashes. Hook errors (network, validation) are expected and should be handled by the component.
How do I test custom hooks?
Use @testing-library/react-hooks or @testing-library/react for integration tests. Test that the hook state updates correctly and side effects run at the right times.