Understand React Hooks the Right Way: From Basics to Bug Prevention & Design Decisions
0. Introduction
React Hooks are a way to manage state and lifecycle without classes (since 16.8).
- Simpler than class syntax
- Easier to reuse logic (custom hooks)
- Easier to test
๐ Goals of this article:
-
Beginners: Use
useState/useEffectcorrectly - Intermediate: Understand render/commit, stale closures, and re-renders caused by function props to make sound design decisions
1. Visualize Render vs. Commit
- Render: React calls your component function to produce virtual DOM.
- Commit: The diff is applied to the real DOM.
๐ The component function is invoked by React itselfโinternally as part of the lifecycle, not by your code directly.
2. Core Hooks
2-1. useState
const [user, setUser] = useState({ name: "Taro", age: 20 });
// โ setUser replaces the whole state
setUser({ age: 21 });
// => { age: 21 } (name is gone)
// โ
Spread to keep the rest
setUser(prev => ({ ...prev, age: 21 }));
useState โ Key Points
- Holds state inside a component
-
setStatereplaces the value (no partial merge) - Updates are async; prefer the functional updater when you need the latest value
- Use it for data that should be reflected in the UI
2-2. useEffect
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);
useEffect โ Key Points
- Runs side effects after render
- Control when it runs with the dependency array
- Return a cleanup function to release resources
- Great for async calls, subscriptions, DOM operations, etc.
2-3. useContext
const ThemeContext = createContext("light");
const theme = useContext(ThemeContext);
useContext โ Key Points
- Share values with descendants without prop drilling
- When the value changes, all consumers re-render
- Handy for global settings (theme, auth info, etc.)
- For large-scale state, consider Redux or Zustand
3. Effect Dependencies, Stale Closures, and Solving Them with useRef
What is a stale closure?
Function components create a new function scope on every render.
As a result, callbacks that captured variables at render time may keep reading old valuesโthatโs a stale closure.
โ Bug caused by a stale closure
function Timer() {
const [count, setCount] = useState(0);
function tick() {
// โ 'count' may be stale here
setCount(count + 1);
}
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []); // 'tick' not in deps
}
๐ tick keeps referring to the initial count, so it never increments beyond 0.
โ
Avoid it with a functional update
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // โ
always uses the latest value
}, 1000);
return () => clearInterval(id);
}, []);
โ
Avoid it with useRef
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(0);
useEffect(() => {
const id = setInterval(() => {
countRef.current += 1;
setCount(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
}
useRef โ Key Points
- Holds a mutable value in
.current - Updating it does not trigger a re-render
- Also used for DOM element refs
- Prevents stale closures by always reading the latest value
- Great for values not needed in the UI or temporary values
4. useCallback and Re-renders Caused by โFunctions Are Objectsโ
What happens when passing a function to a child?
- The parent re-renders.
- The parentโs function definition runs again, creating a new function object.
- The child sees a new prop reference.
- The child re-renders.
๐ If the child is heavy, unnecessary re-renders make the UI feel sluggish.
In JS, functions are objects
function a() {}
function b() {}
console.log(a === b); // false
โ A newly created function is a different object each time.
โ
Optimize with useCallback + React.memo
const Child = React.memo(({ onClick }) => {
console.log("Child render");
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return <Child onClick={handleClick} />;
}
useCallback โ Key Points
- Memoizes a function so its reference stays stable
- Prevents unnecessary child re-renders when passing callbacks
- Stabilizes functions used inside
useEffectdependency arrays - Works best together with
React.memo
5. useEffect ร useReducer: Handling Function Dependencies & Multi-step State
Function dependency example
function Child({ onData }) {
useEffect(() => {
const id = setInterval(() => {
onData(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(id);
}, [onData]); // include the function dependency
}
๐ In the parent, use useCallback to stabilize onData.
Multi-step transitions with useReducer
const initialState = { status: "idle", data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case "FETCH_START": return { status: "loading", data: null, error: null };
case "FETCH_SUCCESS": return { status: "success", data: action.payload, error: null };
case "FETCH_ERROR": return { status: "error", data: null, error: action.error };
default: return state;
}
}
useReducer โ Key Points
- Great for complex, multi-step state transitions
- Name actions to clarify logic
- Often dispatch from inside
useEffect - Ideal for forms, data fetching, step flows
- Frequently combined with function dependencies
6. Custom Hooks
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}
Custom Hooks โ Key Points
- Combine multiple hooks to build reusable logic
- Must be named starting with
use - Extract UI-agnostic logic for reuse
- Perfect for common tasks (window size, fetching, auth checks)
7. Hooks Summary Table
| Hook | Primary Use | Caveats / Pitfalls |
|---|---|---|
| useState | Simple state management |
setState replaces the value. Spread objects yourself when updating. |
| useEffect | Side effects (fetching, subscriptions, DOM) | Wrong deps cause stale closures or infinite loops. |
| useContext | Avoid prop drilling | Value changes re-render all consumers. |
| useRef | DOM refs / non-UI mutable values | Updates donโt re-render. Useful against stale closures. |
| useCallback | Memoize functions / stable props | Functions are objects; new ones re-render children. Use with React.memo. |
| useReducer | Complex state (multi-step transitions) | More setup (actions), but clearer and more readable state. |
| Custom Hooks | Share & reuse logic | Must start with use. Best for UI-agnostic, reusable behavior. |
๐ How to choose
- Small features โ
useState+useEffect - Global shared values โ
useContext - Perf issues from prop functions โ
useCallback+React.memo - Complex flows โ
useReducer - Reusable logic โ Custom hooks
