s asynchronous data fetching with loading states, error handling, and manual retry capability. It uses useRef to track mounted status and prevent state updates after unmounting.
import { useState, useEffect, useCallback, useRef } from 'react';
export function useAsyncResource<T>(
fetcher: () => Promise<T>,
dependencies: unknown[] = []
): AsyncResourceState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const isMountedRef = useRef<boolean>(true);
const fetcherRef = useRef(fetcher);
useEffect(() => {
fetcherRef.current = fetcher;
}, [fetcher]);
const execute = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await fetcherRef.current();
if (isMountedRef.current) {
setData(result);
}
} catch (err) {
if (isMountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
isMountedRef.current = true;
execute();
return () => {
isMountedRef.current = false;
};
}, [execute, ...dependencies]);
return { data, isLoading, error, retry: execute };
}
Architecture Rationale:
fetcherRef ensures the latest fetcher function is always used without triggering unnecessary effect re-runs.
isMountedRef prevents setState calls on unmounted components, eliminating React 18 strict mode warnings.
- Returning an object instead of an array enables named destructuring and allows future properties to be added without breaking consumer code.
retry is memoized with useCallback to maintain referential stability in dependency arrays.
Step 3: Implement usePersistedState
Client-side storage requires careful handling of SSR environments, serialization, and storage quotas. This hook abstracts localStorage and sessionStorage behind a unified interface.
import { useState, useCallback, useEffect } from 'react';
export function usePersistedState<T>(
key: string,
initialValue: T,
options: PersistedStateOptions = {}
): [T, (value: T | ((prev: T) => T)) => void] {
const {
storage = typeof window !== 'undefined' ? window.localStorage : undefined,
serialize = JSON.stringify,
deserialize = JSON.parse,
} = options;
const [state, setState] = useState<T>(() => {
if (!storage) return initialValue;
try {
const raw = storage.getItem(key);
return raw ? (deserialize(raw) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
if (!storage) return;
try {
storage.setItem(key, serialize(state));
} catch (err) {
console.warn(`[usePersistedState] Failed to persist "${key}":`, err);
}
}, [key, state, storage, serialize]);
const setPersistedState = useCallback(
(updater: T | ((prev: T) => T)) => {
setState((prev) => {
const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater;
return next;
});
},
[]
);
return [state, setPersistedState];
}
Architecture Rationale:
- Lazy initialization in
useState prevents synchronous storage reads during every render.
- SSR safety is enforced by checking
typeof window and allowing explicit storage injection for testing.
try/catch blocks handle QuotaExceededError and malformed JSON gracefully.
- The setter accepts both direct values and functional updaters, matching React's native
setState signature.
Step 4: Compose in a Consumer Component
Hooks shine when composed. The following component demonstrates how extracted logic keeps the UI layer focused purely on rendering.
import { useAsyncResource } from './useAsyncResource';
import { usePersistedState } from './usePersistedState';
interface UserProfile {
id: string;
displayName: string;
preferences: { theme: 'light' | 'dark' };
}
export function UserProfilePanel({ userId }: { userId: string }) {
const { data: profile, isLoading, error, retry } = useAsyncResource<UserProfile>(
() => fetch(`/api/users/${userId}`).then((res) => res.json()),
[userId]
);
const [theme, setTheme] = usePersistedState<'light' | 'dark'>(
`user-theme-${userId}`,
'light'
);
if (isLoading) return <div aria-busy="true">Loading profile...</div>;
if (error) return <button onClick={retry}>Retry loading profile</button>;
if (!profile) return null;
return (
<section data-theme={theme}>
<h2>{profile.displayName}</h2>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</section>
);
}
Pitfall Guide
Custom hooks are powerful, but misusing them introduces subtle bugs and performance degradation. Below are the most common production mistakes and their fixes.
1. Conditional Hook Invocation
Explanation: Calling hooks inside if statements, loops, or early returns breaks React's internal hook ordering mechanism. React relies on consistent call order across renders to map state to the correct hook.
Fix: Always call hooks at the top level of the function. Move conditional logic inside the hook or use early returns only after all hooks are declared.
2. The "Utility Function" Trap
Explanation: Developers sometimes create functions starting with use that don't actually call other hooks. This violates React's linting rules and creates false expectations about reactivity.
Fix: Only prefix functions with use if they internally invoke React hooks. Pure utility functions should use standard naming conventions.
3. Dependency Array Neglect
Explanation: Omitting variables from useEffect or useCallback dependency arrays causes stale closures. The hook will capture outdated state or props, leading to inconsistent behavior.
Fix: Use the react-hooks/exhaustive-deps ESLint rule. When dependencies change frequently, consider useRef for mutable values or useCallback with stable references.
4. SSR/SSG Environment Crashes
Explanation: Directly accessing window, document, or localStorage during server-side rendering throws ReferenceError because these globals don't exist in Node.js.
Fix: Guard browser APIs with typeof window !== 'undefined' or inject storage adapters. Use useEffect for client-only side effects to ensure they run only after hydration.
5. State Mutation Leaks
Explanation: Returning mutable objects or arrays directly from a hook allows consumers to mutate state outside React's control, bypassing re-renders and breaking immutability guarantees.
Fix: Return frozen objects or use Object.freeze() in development. Encourage consumers to use provided setters. Consider returning readonly types in TypeScript.
Explanation: Extracting logic into a hook when it's only used in one component adds indirection without benefit. It increases cognitive load and complicates debugging.
Fix: Apply the "Rule of Three": extract only when the same logic appears in three or more components, or when a single component exceeds 150 lines due to side effects.
7. Mixing UI Concerns with Hook Logic
Explanation: Hooks that return JSX or manipulate DOM nodes violate separation of concerns. They become tightly coupled to specific rendering contexts and lose reusability.
Fix: Hooks should only return data, state, and callbacks. Keep DOM manipulation and JSX generation inside components or custom renderers.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Logic shared across 3+ components | Custom Hook | Centralizes behavior, reduces duplication, enables independent testing | Low maintenance, high ROI |
| Global state needed by deep component trees | Context API | Avoids prop drilling, provides reactive updates across subtrees | Moderate re-render overhead |
| One-off utility (no React state/effects) | Plain Function | Simpler, faster, no hook rules to enforce | Zero React overhead |
| Cross-cutting concern requiring DOM access | Custom Hook + useRef | Keeps DOM logic isolated while maintaining React lifecycle awareness | Low, requires careful cleanup |
| Complex state machine with many transitions | useReducer + Custom Hook | Predictable state transitions, easier debugging, testable dispatch logic | Moderate initial setup |
Configuration Template
Use this standardized structure for all custom hooks in your codebase. It enforces consistency, improves discoverability, and simplifies onboarding.
// hooks/useResourceSync.ts
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Synchronizes external data with component state.
* @template T - Expected data shape
* @param fetcher - Async function returning data
* @param deps - Dependencies that trigger re-fetching
*/
export function useResourceSync<T>(
fetcher: () => Promise<T>,
deps: unknown[] = []
) {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [error, setError] = useState<Error | null>(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
let abortController = new AbortController();
const run = async () => {
setStatus('loading');
try {
const result = await fetcher();
if (isMounted.current) {
setData(result);
setStatus('success');
}
} catch (err) {
if (isMounted.current && !(err instanceof DOMException && err.name === 'AbortError')) {
setError(err instanceof Error ? err : new Error(String(err)));
setStatus('error');
}
}
};
run();
return () => {
isMounted.current = false;
abortController.abort();
};
}, deps);
const reset = useCallback(() => {
setData(null);
setStatus('idle');
setError(null);
}, []);
return { data, status, error, reset };
}
Quick Start Guide
- Scaffold the hook file: Create
src/hooks/useYourHook.ts and import required React primitives.
- Define the contract: Write TypeScript interfaces for inputs and outputs. This prevents runtime type mismatches.
- Implement with cleanup: Use
useEffect return functions to cancel subscriptions, abort fetches, or clear timers. Always guard against unmounted state updates.
- Test in isolation: Use
@testing-library/react's renderHook to verify state transitions, error handling, and dependency behavior without mounting components.
- Integrate and monitor: Replace duplicated logic in components with the new hook. Track bundle size impact and re-render frequency using React DevTools Profiler.