In this article, I'm continuing with the next set of React hooks and patterns that I found crucial when I moved from building small demos to more realistic applications.
These cover side effects, performance optimizations, structured state, and rendering concerns.
π€ Quick Answers
What is useEffect in React?
useEffect is a React hook for performing side effects in functional components. It runs after every render and can handle data fetching, subscriptions, timers, and DOM manipulation. It replaces lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.
When should I use useReducer instead of useState?
Use useReducer when: You have complex state logic with multiple sub-values, the next state depends on the previous one, or you want predictable state updates similar to Redux. Use useState for simple state management.
What's the difference between useCallback and useMemo?
useCallback memoizes functions to prevent child re-renders, while useMemo memoizes computed values to avoid expensive calculations. Both accept dependency arrays and re-compute when dependencies change.
useEffect β Side Effects, Dependencies, Cleanup, Fetching Data
When I first started with React, I thought everything had to be inside the component render function. But soon I learned that anything like fetching data, setting up timers, or subscribing to external services belongs in side effects.
That's exactly what useEffect is for. It's React's way of saying: run this after rendering.
The three main things I had to master were:
- Dependencies β controlling when the effect runs.
- Cleanup β returning a function to clean up subscriptions or timers.
- Replacing lifecycle methods β
componentDidMount,componentDidUpdate, andcomponentWillUnmountcan all be represented withuseEffect.
// Basic: run once on mount
useEffect(() => {
console.log("Mounted");
}, []);
// With dependencies: runs when count changes
useEffect(() => {
console.log("Count changed");
}, [count]);
// Cleanup: remove listener when unmounted
useEffect(() => {
const id = setInterval(() => setTime(Date.now()), 1000);
return () => clearInterval(id);
}, []);
Fetching data is one of the most common use cases:
export function Users() {
const [users, setUsers] = React.useState([]);
useEffect(() => {
fetch("/api/users")
.then(res => res.json())
.then(setUsers);
}, []);
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
+ Use when
- Running code after render (fetch, timer, subscriptions)
- Replacing lifecycle methods in function components
- Cleaning up resources on unmount
- Avoid when
- You can compute something during render without side effects
- Dependencies are unclear β leads to infinite loops
- Running heavy synchronous code (better in
useLayoutEffect)
useReducer β Structured State Management
At first, I used useState for everything. But when state grew more complex (like forms or multi-step wizards), it became messy.
That's when useReducer became my friend. It brings structured updates similar to Redux, but without extra libraries.
type State = { count: number };
type Action = { type: "inc" } | { type: "dec" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "inc": return { count: state.count + 1 };
case "dec": return { count: state.count - 1 };
}
}
export function Counter() {
const [state, dispatch] = React.useReducer(reducer, { count: 0 });
return (
<div>
<button onClick={() => dispatch({ type: "dec" })}>-</button>
{state.count}
<button onClick={() => dispatch({ type: "inc" })}>+</button>
</div>
);
}
Lazy initialization is also powerful when the initial state requires computation:
function init(count: number) {
return { count };
}
const [state, dispatch] = React.useReducer(reducer, 5, init);
+ Use when
- State logic is complex or involves many transitions
- You want predictable updates like in Redux
- Pairing with Context to provide a global store
- Avoid when
- State is simple β
useStateis enough - Overhead of reducer/actions outweighs simplicity
useCallback β Stable Functions
I ran into cases where passing inline arrow functions caused child components to re-render unnecessarily.
That's when I learned useCallback: it memoizes functions so they only change when dependencies change.
const handleClick = useCallback(() => {
console.log("Clicked");
}, []);
+ Use when
- Passing callbacks to memoized children
- Avoiding unnecessary renders caused by new function identities
- Functions depend on stable values (like IDs)
- Avoid when
- Function creation is cheap and child isn't memoized
- Adding dependencies makes code harder to follow
memo β Memoized Components
Sometimes, child components kept re-rendering even though props didn't change.
React.memo solved this by memoizing the component itself.
const Child = React.memo(function Child({ value }: { value: number }) {
console.log("Rendered");
return <div>{value}</div>;
});
+ Use when
- Component renders often with the same props
- Performance issues due to re-renders
- Paired with
useCallbackanduseMemofor stable inputs
- Avoid when
- Component is cheap to render
- Props change frequently anyway
- Over-optimization clutters the code
useMemo β Memoized Calculations
useMemo is like useCallback, but for values.
It's great for expensive calculations you don't want to redo every render.
const sorted = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
+ Use when
- Expensive calculations (sorting, filtering, heavy math)
- Derived state based on props/state
- Preventing re-creation of objects/arrays passed to children
- Avoid when
- Calculation is trivial
- You don't actually need memoization (adds complexity)
Keys in Lists β Object IDs vs Index
Early on, I just wrote key={i} when mapping lists. But this causes React to re-use DOM nodes incorrectly, leading to flickering and bugs.
Now I always use stable identifiers, like database IDs.
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
+ Use when
- Key is stable and unique (database IDs, UUIDs)
- You want React to correctly preserve component state between renders
- Avoid when
- Using array index as key (unless it's static and never changes order)
million.js β Faster Rendering
At some point I explored million.js, a library that accelerates React rendering by using a faster virtual DOM.
The integration was straightforward, and in lists with thousands of rows, I noticed a clear performance boost.
import { block } from "million/react";
const FastList = block(function FastList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item) => <li key={item}>{item}</li>)}
</ul>
);
});
+ Use when
- Rendering very large lists or complex UIs
- You need performance beyond React's defaults
- Avoid when
- Small apps (not worth the extra dependency)
- Complexity isn't justified by actual performance issues
Async/Await in useEffect β Cleaner Code
Originally, I nested then inside useEffect, but it quickly got messy.
Using async/await inside an effect makes the code easier to read.
useEffect(() => {
let ignore = false;
async function fetchData() {
const res = await fetch("/api/data");
const json = await res.json();
if (!ignore) setData(json);
}
fetchData();
return () => { ignore = true; };
}, []);
The cleanup with ignore is important to avoid setting state on an unmounted component.
+ Use when
- Fetching data in effects
- Wanting more readable code with async/await
- Avoid when
- Forgetting cleanup β memory leaks
- Data fetching libraries (React Query, SWR) might be a better fit
useLayoutEffect vs useEffect
One time I tried to measure element size in useEffect, but saw flicker. That's when I discovered useLayoutEffect.
Unlike useEffect, it runs synchronously after DOM mutations but before painting, so you can measure and make adjustments without flicker.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
console.log("Size", rect);
}, []);
+ Use when
- Reading layout/sizes immediately after render
- Avoiding flicker in measurements or DOM adjustments
- Avoid when
- Most side effects (useEffect is lighter)
- Overuse can block painting and hurt performance
Closing Thoughts
In Part 2, I went deeper into managing side effects, performance, and structured state.
The biggest lessons for me were:
useEffecttaught me to separate rendering from side effects.useReducergave me structure whenuseStatewasn't enough.useCallback,useMemo, andmemoshowed me how to avoid useless re-renders.- Stable keys are critical for avoiding flicker.
- Libraries like million.js can push performance even further when needed.
- Async/await made my effects cleaner, and
useLayoutEffectfixed visual glitches.
This concludes Part 2. In Part 3, I'll explore custom hooks, advanced context patterns, and performance tricks for larger applications.