DEV Community

Learcise
Learcise

Posted on

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 / useEffect correctly
  • 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 }));
Enter fullscreen mode Exit fullscreen mode

useState — Key Points

  • Holds state inside a component
  • setState replaces 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

👉 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

✅ 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);
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

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?

  1. The parent re-renders.
  2. The parent’s function definition runs again, creating a new function object.
  3. The child sees a new prop reference.
  4. 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
Enter fullscreen mode Exit fullscreen mode

→ 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} />;
}
Enter fullscreen mode Exit fullscreen mode

useCallback — Key Points

  • Memoizes a function so its reference stays stable
  • Prevents unnecessary child re-renders when passing callbacks
  • Stabilizes functions used inside useEffect dependency 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
}
Enter fullscreen mode Exit fullscreen mode

👉 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)