React Hooks transformed how we write React applications. Before hooks, sharing stateful logic between components required complex patterns like Higher-Order Components (HOCs) and render props — patterns that created deeply nested "wrapper hell" and made code difficult to reason about.
With hooks, the same logic is encapsulated in a simple function you can drop into any component. After building dozens of production React applications, I've developed a set of patterns for React hooks that I apply consistently. This guide walks you through all of them — from the fundamentals to advanced custom hook patterns you can use immediately.
Table of Contents
- Why Hooks Changed Everything
- useState — More Than You Think
- useEffect — The Right Way
- useRef — Beyond DOM References
- useContext — Shared State Without Redux
- useReducer — Complex State Logic
- useMemo & useCallback — When to Actually Use Them
- Custom Hooks — The Real Power
- React Hooks Best Practices Checklist
1. Why Hooks Changed Everything
Before hooks (React < 16.8), if you needed state or lifecycle methods, you had to write a class component:
// ❌ OLD WAY — Class component with lifecycle methods
class UserProfile extends React.Component {
state = { user: null, loading: true };
componentDidMount() {
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.setState({ loading: true });
fetchUser(this.props.userId).then(user => {
this.setState({ user, loading: false });
});
}
}
render() {
if (this.state.loading) return <Spinner />;
return <div>{this.state.user.name}</div>;
}
}
The same logic with hooks:
// ✅ NEW WAY — Function component with hooks
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(userId).then(user => {
setUser(user);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
};
Cleaner, shorter, and — crucially — the data-fetching logic can now be extracted into a reusable custom hook. That's the real win.
2. useState — More Than You Think
useState looks simple but has subtleties that trip up even experienced developers.
Functional Updates
When new state depends on previous state, always use the functional form:
// ❌ BAD — can have stale state in closures
const increment = () => setCount(count + 1);
// ✅ GOOD — always works, even inside async callbacks or event batching
const increment = () => setCount(prev => prev + 1);
This matters especially when called multiple times in rapid succession:
// ❌ This will only increment by 1, not 3
const handleTripleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
// ✅ This correctly increments by 3
const handleTripleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
Lazy Initial State
If your initial state requires an expensive computation, pass a function to avoid re-running it on every render:
// ❌ BAD — parseFilters runs on EVERY render
const [filters, setFilters] = useState(parseFilters(window.location.search));
// ✅ GOOD — parseFilters runs only once
const [filters, setFilters] = useState(() => parseFilters(window.location.search));
State Batching
In React 18+, state updates are automatically batched — even inside setTimeout, Promise, and native event handlers. This means fewer re-renders out of the box:
// In React 18, this causes ONE re-render (not two)
const handleSubmit = async () => {
const result = await submitForm(formData);
setLoading(false); // ─┐ Batched together
setSuccess(true); // ─┘ → single re-render
};
3. useEffect — The Right Way
useEffect is powerful but commonly misused. Let's break down the rules.
The Dependency Array — No Lies
Every value from the component scope that is used inside useEffect must be in the dependency array. Omitting dependencies leads to stale closure bugs:
// ❌ BAD — stale closure: `userId` is captured from the first render only
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // Missing userId
// ✅ GOOD — re-runs correctly whenever userId changes
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
Use the eslint-plugin-react-hooks ESLint plugin to catch these automatically.
Cleanup Functions
Always return a cleanup function when your effect sets up a subscription, timer, or async operation:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// Cleanup: cancel the in-flight request when component unmounts or userId changes
return () => controller.abort();
}, [userId]);
// Cleanup for event listeners
useEffect(() => {
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Separating Concerns
Each useEffect should do one thing. Split unrelated logic into separate effects:
// ❌ BAD — two unrelated concerns in one effect
useEffect(() => {
fetchUser(userId).then(setUser);
document.title = `Profile - ${username}`;
}, [userId, username]);
// ✅ GOOD — separated, easier to reason about and debug
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
useEffect(() => {
document.title = `Profile - ${username}`;
}, [username]);
4. useRef — Beyond DOM References
useRef is commonly used to access DOM nodes, but it has a second powerful use case: storing mutable values that persist across renders without triggering a re-render.
Tracking Previous Values
const usePrevious = (value) => {
const ref = useRef(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current; // Returns the value from the previous render
};
// Usage
const MyComponent = ({ count }) => {
const prevCount = usePrevious(count);
return (
<p>
Current: {count} | Previous: {prevCount}
</p>
);
};
Storing Stable Callbacks
useRef can be used to create a "stable" callback reference — useful when you need a callback inside useEffect but don't want to re-run the effect when it changes:
const useEventCallback = (fn) => {
const ref = useRef(fn);
useEffect(() => {
ref.current = fn;
});
return useCallback((...args) => ref.current(...args), []);
};
// Usage — the effect doesn't re-run when `onSuccess` changes
const MyForm = ({ onSuccess }) => {
const stableOnSuccess = useEventCallback(onSuccess);
useEffect(() => {
subscribeToForm(stableOnSuccess);
return () => unsubscribeFromForm(stableOnSuccess);
}, [stableOnSuccess]); // ← stable reference, never changes
};
Tracking Whether a Component Is Mounted
Prevent the "Can't perform a React state update on an unmounted component" warning:
const useIsMounted = () => {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return isMounted;
};
// Usage
const DataFetcher = ({ url }) => {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(result => {
if (isMounted.current) { // ← Check before updating state
setData(result);
}
});
}, [url]);
};
5. useContext — Shared State Without Redux
useContext combined with useReducer often eliminates the need for Redux in medium-complexity apps.
Theme / Auth Context Pattern
// context/AuthContext.js
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is already logged in (e.g., from localStorage/session)
const storedUser = JSON.parse(localStorage.getItem('user'));
if (storedUser) setUser(storedUser);
setLoading(false);
}, []);
const login = async (credentials) => {
const user = await authService.login(credentials);
localStorage.setItem('user', JSON.stringify(user));
setUser(user);
};
const logout = () => {
localStorage.removeItem('user');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
// Custom hook to consume the context — always export this, not the context itself
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// Usage inside any component, anywhere in the tree
const Navbar = () => {
const { user, logout } = useAuth();
return (
<nav>
{user ? (
<>
<span>Welcome, {user.name}</span>
<button onClick={logout}>Sign Out</button>
</>
) : (
<Link to="/login">Sign In</Link>
)}
</nav>
);
};
Performance Note: Context re-renders all consumers when its value changes. Split your context into multiple providers (e.g.,
AuthContextandThemeContext) so a theme change doesn't re-render every component that consumes auth.
6. useReducer — Complex State Logic
When state updates are complex or interdependent, useReducer is far cleaner than multiple useState calls.
// ❌ Messy — 5 related useState calls, hard to manage atomically
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// ✅ Clean — useReducer manages all related state as a unit
const initialState = {
items: [],
loading: false,
error: null,
page: 1,
hasMore: true,
};
const feedReducer = (state, action) => {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return {
...state,
loading: false,
items: [...state.items, ...action.payload.items],
page: state.page + 1,
hasMore: action.payload.items.length === action.payload.limit,
};
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
};
const InfiniteList = () => {
const [state, dispatch] = useReducer(feedReducer, initialState);
const loadMore = async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await fetchItems({ page: state.page, limit: 20 });
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
};
// ...
};
7. useMemo & useCallback — When to Actually Use Them
These hooks are frequently overused. Here's a precise decision framework:
Use useMemo when:
- Computing a filtered/sorted array from a large dataset
- Creating an object or array passed as a prop to a
React.memocomponent - Performing genuinely expensive calculations (parsing, complex math)
Use useCallback when:
- Passing a callback to a memoized child (
React.memo) - Passing a callback into a
useEffectdependency array - The function is a dependency of another hook
// ✅ Correct use of useMemo
const expensiveReport = useMemo(() => {
return rawData
.filter(row => row.status === activeFilter)
.map(row => ({ ...row, profit: row.revenue - row.cost }))
.sort((a, b) => b.profit - a.profit);
}, [rawData, activeFilter]);
// ✅ Correct use of useCallback
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // No dependencies → stable reference forever
// ❌ Useless memoization — wrapping a primitive computation
const double = useMemo(() => count * 2, [count]); // Just write: count * 2
8. Custom Hooks — The Real Power
Custom hooks are where hooks truly shine. They let you extract, reuse, and test stateful logic independently of any UI.
useFetch — Data Fetching with Loading & Error States
const useFetch = (url, options = {}) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
};
// Usage
const ProductList = () => {
const { data, loading, error } = useFetch('/api/products');
if (loading) return <Spinner />;
if (error) return <ErrorBanner message={error} />;
return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
};
useLocalStorage — Persistent State
const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
};
// Usage — works exactly like useState but persists to localStorage
const Settings = () => {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'en');
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
);
};
useDebounce — Throttle Expensive Operations
const useDebounce = (value, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
};
// Usage — perfect for search inputs
const SearchBar = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const { data: results } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
<ResultsList results={results} />
</div>
);
};
useForm — Reusable Form Logic
const useForm = (initialValues, validate) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) {
setErrors(validate(values));
}
};
const handleSubmit = (onSubmit) => async (e) => {
e.preventDefault();
const validationErrors = validate ? validate(values) : {};
setErrors(validationErrors);
setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {}));
if (Object.keys(validationErrors).length === 0) {
setIsSubmitting(true);
await onSubmit(values);
setIsSubmitting(false);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit, reset };
};
// Usage
const ContactForm = () => {
const { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit } = useForm(
{ name: '', email: '', message: '' },
(vals) => {
const errs = {};
if (!vals.name) errs.name = 'Name is required';
if (!vals.email.includes('@')) errs.email = 'Valid email required';
if (vals.message.length < 10) errs.message = 'Message too short';
return errs;
}
);
const onSubmit = handleSubmit(async (data) => {
await submitContactForm(data);
});
return (
<form onSubmit={onSubmit}>
<input
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.name && errors.name && <span className="error">{errors.name}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
};
9. React Hooks Best Practices Checklist
Apply this every time you write a hook or component:
Rules of Hooks (Non-Negotiable):
- [ ] Only call hooks at the top level — never inside loops, conditions, or nested functions
- [ ] Only call hooks from React function components or other custom hooks
useState:
- [ ] Use functional updates (
prev => ...) when new state depends on old state - [ ] Use lazy initialization (
() => ...) for expensive initial state computations - [ ] Group related state into objects or switch to
useReducerif state has 4+ fields
useEffect:
- [ ] Every value used inside the effect is listed in the dependency array
- [ ] Every effect with subscriptions, timers, or async operations has a cleanup function
- [ ] Each effect handles a single responsibility (split unrelated effects)
- [ ] Install and configure eslint-plugin-react-hooks
Custom Hooks:
- [ ] Named with the
useprefix - [ ] Each hook has a single, clear responsibility
- [ ] Logic is generic enough to be reused across 2+ components
- [ ] Side effects inside the hook have proper cleanup
Performance:
- [ ]
useCallbackis only used when passing callbacks to memoized children or hooks - [ ]
useMemois only used for genuinely expensive computations - [ ] Don't premature optimize — profile first
Conclusion
Hooks represent the most significant shift in React's history. But mastering them isn't about memorizing their API — it's about understanding when and why to use each one.
The real lever is custom hooks. Once you start extracting business logic into useFetch, useForm, useDebounce, and domain-specific hooks, you'll find your components become dramatically simpler — pure expressions of what to render, with all the complex logic neatly tucked away in composable, testable functions.
The patterns in this guide are production-tested across dozens of React applications. Start with the custom hooks above, wire them into your own projects, and adapt them to your specific needs.
What's your favourite custom hook pattern? Drop it in the comments below 👇
For more React deep dives, architectural guides, and full-stack content, visit muhammadarslan.codes or connect with me on LinkedIn and GitHub.
Top comments (0)