React Hooks Explained Like You’re 5 (useState, useEffect, useRef)

Posted November 17, 2025 by Karol Polakowski

This article explains three fundamental React hooks in plain language (think: simple analogies) and then maps those ideas to practical, production-ready code examples and gotchas.


Why hooks? A tiny analogy

  • useState is like a toy box with a label: you put a toy in or take it out, and the toy box tells the room to update so everyone sees the change.
  • useEffect is like a chore list: when something changes (you add a new chore), you run tasks (do the chore) and optionally clean up after (put things back).
  • useRef is like a special sticky note on a toy: it can hold a value or point to a toy without telling the room to re-check everything when you change it.

These simple metaphors map directly to how React manages UI, side effects, and mutable references.

useState: local, reactive state

useState stores a value that, when changed, tells React to re-render the component. Think: when you change the toy in the toy box, the room (UI) notices and updates.

Key points:

  • Updating state schedules a re-render.
  • State updates may be batched and are asynchronous-ish; if you need the previous value, use the updater function form.
  • Initial state can be expensive—pass a function to initialize lazily.

Example: simple counter

const Counter = () => {
  const [count, setCount] = React.useState(0);

  const increment = () => setCount(prev => prev + 1);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
};

Lazy initialization example (useful if computing initial value is expensive):

const ExpensiveInit = () => {
  const computeInitial = () => {
    // expensive work
    let x = 0;
    for (let i = 0; i < 1000000; i++) x += i;
    return x;
  };

  const [value, setValue] = React.useState(() => computeInitial());
  return <div>{value}</div>;
};

Common pitfall: doing setState(value + 1) inside an event where multiple updates happen in one tick can cause lost updates. Use setState(prev => prev + 1) instead.

useEffect: side effects and lifecycle

useEffect runs code after render. Think: the chore list runs after the room is set up. You can:

  • run something once on mount,
  • run something whenever a value changes,
  • clean up when the component unmounts or before the effect runs again.

Effect signature:

React.useEffect(() => { /  do something  / return () => { /  cleanup  / } }, [deps])

Examples:

1) Run once on mount (componentDidMount):

React.useEffect(() => {
  console.log('Mounted');
}, [/* empty deps */]);

2) Fetching data with cleanup (abort on unmount):

const DataFetcher = (<{ url }>) => {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      });

    return () => controller.abort();
  }, [url]);

  if (error) return <div>Error</div>;
  if (!data) return <div>Loading...</div>;
  return <div>{JSON.stringify(data)}</div>;
};

3) Be careful with dependencies: always list everything the effect uses (props, state, functions). Missing deps cause stale closures.

Common pitfalls and tips:

  • Empty deps array runs once; missing deps can lead to bugs.
  • Put stable functions (useCallback) or values in deps to avoid triggering unnecessarily.
  • Clean up subscriptions, timers, and event listeners to avoid memory leaks.

useRef: persistent, mutable container

useRef is a tiny box that survives re-renders but changing it doesn’t cause a re-render. Two common uses:

1) Referencing DOM nodes.

2) Storing mutable values across renders without triggering updates.

Example: DOM focus

const FocusInput = () => {
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    if (inputRef.current) inputRef.current.focus();
  }, []);

  return <input ref={inputRef} />;
};

Example: counting renders without re-rendering

const RenderCounter = () => {
  const renders = React.useRef(0);
  renders.current += 1;

  return <div>Renders: {renders.current}</div>;
};

Advanced: using refs to avoid stale closures (interval example)

const Timer = () => {
  const callbackRef = React.useRef(() => {});
  React.useEffect(() => {
    callbackRef.current = () => console.log('tick');
  });

  React.useEffect(() => {
    const id = setInterval(() => callbackRef.current(), 1000);
    return () => clearInterval(id);
  }, []);

  return <div>Timer running</div>;
};

This pattern keeps the interval stable while updating the callback logic via the ref.

Practical comparisons

  • State (useState) is for values that affect rendering.
  • Ref (useRef) is for values that must persist but should not trigger a render when changed.
  • Effect (useEffect) is for side effects: network calls, subscriptions, timers, manual DOM work.

When in doubt: if updating the value must change what the user sees, use state. If it’s internal bookkeeping, use a ref.

Common gotchas checklist

  • Forgetting to include dependencies in useEffect leads to stale data.
  • Using refs to store values you expect to see reflected in the UI (they won’t re-render).
  • Calling setState with computed value instead of functional update when the new state depends on the old state.
  • Running expensive work in render rather than initializing with a lazy function in useState.
  • Not cleaning up effects (listeners, timers, subscriptions) causing leaks.

Best practices

  • Keep effects focused: one effect per concern (fetching, subscribing, DOM updates).
  • Use functional updates (setState(prev => …)) when new state depends on previous state.
  • Prefer state for UI-driven values; refs for mutable values that shouldn’t trigger UI updates.
  • Use ESLint plugin (eslint-plugin-react-hooks) to catch missing dependencies.
  • Memoize handlers when passing to deeply optimized children (useCallback) but don’t over-optimize prematurely.

Short reference (cheat sheet)

  • useState(initial) -> [value, setValue]: reactive local state
  • useEffect(effect, deps): run side effects after render; return optional cleanup
  • useRef(initial): mutable container with .current that persists across renders

Closing thoughts

Think of states as the things the UI must show, effects as chores you run when the UI changes, and refs as secret notes you keep to yourself. Keep hooks small, predictable, and explicit—and rely on the React Hooks ESLint rules to prevent common mistakes.

Further reading: React docs on Hooks, patterns for avoiding stale closures, and the React Hooks FAQ.