Overview
Advanced React patterns for production applications. Covers state management beyond useState, performance optimization, data fetching, and composable custom hooks.
Custom Hook: Async State
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useAsync<T>(asyncFn: () => Promise<T>, deps: unknown[] = []) {
const [state, setState] = useState<AsyncState<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
asyncFn()
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((error) => {
if (!cancelled) setState({ status: "error", error: error as Error });
});
return () => { cancelled = true; };
}, deps);
return state;
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const state = useAsync(() => fetchUser(userId), [userId]);
if (state.status === "loading") return <Skeleton />;
if (state.status === "error") return <ErrorMessage error={state.error} />;
if (state.status === "success") return <Profile user={state.data} />;
return null;
}
useReducer for Complex State
type State = {
items: Item[];
selectedId: string | null;
filter: "all" | "active" | "archived";
isLoading: boolean;
};
type Action =
| { type: "SET_ITEMS"; items: Item[] }
| { type: "SELECT"; id: string }
| { type: "DESELECT" }
| { type: "SET_FILTER"; filter: State["filter"] }
| { type: "SET_LOADING"; loading: boolean };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_ITEMS":
return { ...state, items: action.items, isLoading: false };
case "SELECT":
return { ...state, selectedId: action.id };
case "DESELECT":
return { ...state, selectedId: null };
case "SET_FILTER":
return { ...state, filter: action.filter };
case "SET_LOADING":
return { ...state, isLoading: action.loading };
default:
return state;
}
}
function ItemList() {
const [state, dispatch] = useReducer(reducer, {
items: [],
selectedId: null,
filter: "all",
isLoading: false,
});
const filtered = useMemo(
() => state.items.filter(item =>
state.filter === "all" || item.status === state.filter
),
[state.items, state.filter]
);
return (/* ... */);
}
useMemo and useCallback: When to Use
// useMemo: expensive computations
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]); // only recompute when items changes
// useCallback: stable function reference for child components
const handleDelete = useCallback((id: string) => {
dispatch({ type: "DELETE", id });
}, []); // stable reference — doesn't cause unnecessary re-renders in children
// When NOT to memoize (premature optimization):
// - Simple value derivations
// - Functions passed to DOM elements (not child components)
// - Values used only once
Context with Selector Pattern
Avoid unnecessary re-renders by splitting context or using selectors:
// Split contexts by update frequency
const UserDataContext = createContext<User | null>(null);
const UserActionsContext = createContext<UserActions | null>(null);
function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const actions = useMemo(() => ({
updateName: (name: string) => setUser(u => u ? { ...u, name } : u),
logout: () => setUser(null),
}), []);
return (
<UserActionsContext.Provider value={actions}>
<UserDataContext.Provider value={user}>
{children}
</UserDataContext.Provider>
</UserActionsContext.Provider>
);
}
// Consumers only re-render when their specific slice changes
function UserName() {
const user = useContext(UserDataContext);
return <span>{user?.name}</span>;
}
function LogoutButton() {
const { logout } = useContext(UserActionsContext)!;
return <button onClick={logout}>Logout</button>; // stable reference = no re-render on user change
}
TanStack Query (Data Fetching)
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function ProjectList() {
const { data, isLoading, error } = useQuery({
queryKey: ["projects"],
queryFn: () => fetch("/api/projects").then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
});
const queryClient = useQueryClient();
const createProject = useMutation({
mutationFn: (name: string) =>
fetch("/api/projects", { method: "POST", body: JSON.stringify({ name }) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return (
<div>
{data.map(p => <ProjectCard key={p.id} project={p} />)}
<button onClick={() => createProject.mutate("New Project")}>
Create
</button>
</div>
);
}
Custom Hook: Local Storage State
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = typeof newValue === "function"
? (newValue as (prev: T) => T)(prev)
: newValue;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
}, [key]);
return [value, setStoredValue] as const;
}
Custom Hook: Debounced Value
function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
// Usage: debounce search input
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Only fires 300ms after user stops typing
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
}
Portal Pattern
import { createPortal } from "react-dom";
function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById("modal-root")!
);
}
Performance Checklist
[ ] Large lists use virtualization (react-window or tanstack-virtual)
[ ] Images lazy-loaded and sized (next/image)
[ ] Bundle split at route level (Next.js default, manual with lazy())
[ ] No anonymous object/array literals in JSX props (creates new ref each render)
[ ] Context value memoized with useMemo
[ ] Event handlers from useCallback when passed to memoized children
[ ] React.memo on components with expensive render and stable props