React Hooks Deep Dive: useState, useEffect, and Custom Hooks
Go beyond the basics of React hooks. Learn the rules, pitfalls, and patterns for useState, useEffect, and building reusable custom hooks.
Table of Contents
React Hooks Deep Dive: useState, useEffect, and Custom Hooks
Hooks transformed React by letting you use state and lifecycle features in function components. Understanding them deeply — including their pitfalls — separates good React code from great React code.
Rules of Hooks
useState in Depth
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// Functional update — always use when new state depends on old state
const increment = () => setCount(prev => prev + 1);
return <button onClick={increment}>Count: {count}</button>;
}
Object State Pattern
interface FormState {
name: string;
email: string;
loading: boolean;
}
function Form() {
const [form, setForm] = useState<FormState>({
name: '',
email: '',
loading: false,
});
const updateField = (field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement>) =>
setForm(prev => ({ ...prev, [field]: e.target.value }));
return (
<form>
<input value={form.name} onChange={updateField('name')} />
<input value={form.email} onChange={updateField('email')} />
</form>
);
}
useEffect Mastery
useEffect runs after every render by default. The dependency array controls when:
useEffect(() => {
// Runs after every render
});
useEffect(() => {
// Runs once on mount
}, []);
useEffect(() => {
// Runs when userId changes
fetchUser(userId);
}, [userId]);
Cleanup Functions
Always return a cleanup function for subscriptions, timers, and event listeners:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort(); // cleanup on unmount or re-run
}, [userId]);
Performance Hooks
useCallback
Memoizes a function reference to prevent unnecessary re-renders of children:
const handleSubmit = useCallback(
async (data: FormData) => {
await api.submit(data);
onSuccess();
},
[onSuccess] // recreate only when onSuccess changes
);
useMemo
Memoizes an expensive computed value:
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items] // only re-sort when items changes
);
Building Custom Hooks
Custom hooks let you extract and reuse stateful logic:
// hooks/useFetch.ts
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState(s => ({ ...s, loading: true }));
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
})
.then(data => setState({ data, loading: false, error: null }))
.catch(e => {
if (e.name !== 'AbortError') {
setState({ data: null, loading: false, error: e.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
Usage:
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMsg message={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}
Custom hooks are one of the most powerful patterns in React. Extract any stateful logic that's used in more than one component into a custom hook.