DEV Community

Cover image for Mastering useState — React State Deep Dive: Basics, Gotchas & Patterns
Ali Aslam
Ali Aslam

Posted on • Edited on

Mastering useState — React State Deep Dive: Basics, Gotchas & Patterns

You click a button. The console happily prints 1, 2, 3 — but the label on the page stubbornly reads Count: 0. Infuriating? Absolutely. Confusing? Totally!!!. That mismatch is exactly what this article fixes.

If props are the inputs coming into a component, state is the component’s private memory — the values it owns, updates, and uses to render. Change the memory the right way and React redraws the UI. Mutate a local variable instead, and React doesn’t care.

This article is part of our React 19 Deep Dive Series. In this three-part sequence we’ll:

  1. Define what “state” means in React and why it’s different from plain variables.
  2. Walk through useState basics.
  3. Cover updater functions, stale closures, lifting state up, controlled vs uncontrolled inputs
  4. Finish with pitfalls, performance, and how useState fits into React 19

Not for the faint of heart — deep dive ahead (≈15 minutes). Proceed if you want to get into the weeds

Quick, 8-minute primer from our React Crash Course — a quick live-coding demo that’s useful as a primer only; it doesn’t go in-depth. Read the full deep dive below for the detailed explanations and edge cases.

This article serves both as a beginner's guide, as well as a reference for later use for usage guide/troubleshooting. It would use some concepts not introduced yet, but they would be explained later with dedicated articles later in the series.


Table of contents

Part 1 — Understanding State

Part 2 — Updating State Safely

Part 3 — Rules, Pitfalls & Performance


What Is “State” in React?

State is:

  • Data that belongs to a specific component.
  • Data that can change during the component’s lifetime.
  • Data that, when changed, causes the component to re-render.

Think of a component as a whiteboard. Props are the sticky notes other people pin there — they come from outside and you read them as-is. State is what you write on the board — your own notes that can change while you work, whether you update them directly or they change in response to something happening (like a button click, a timer, or data arriving).


State vs Regular Variables — the “Why” (with a tiny demo)

You might ask: “Why not just use a let inside the component?” Fair question. At first glance, you might think “If I change a variable, the UI should change too.”

That’s true in vanilla JS if you manually update the DOM yourself — but in React, the UI only changes when the component re-renders. And React re-renders only when state or props change, not regular variables. Here’s a tiny example:

Regular variable example (won’t update UI):

function Counter() {
  let count = 0;

  function increment() {
    count++;
    console.log(count);
  }

  return <button onClick={increment}>Count: {count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Clicking logs numbers to the console, but the button label never changes. React doesn’t re-render because it only tracks state and props.

Quick definition — what “re-render” means:
A re-render means React runs the component function again, figures out what the UI should look like now, diffs that against the current DOM, and then updates the DOM where necessary.

useState example (UI updates):

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  function increment() {
    // Using the functional updater is a good habit:
    setCount(prev => prev + 1);
  }

  return <button onClick={increment}>Count: {count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

setCount tells React “this component’s data changed — please re-render.” You get the visible update and the predictable behavior you expect.

Key insight: React re-renders when state or props change — not when arbitrary local variables mutate.


useState — the basic pattern

useState lets function components hold onto values across renders.

Syntax

const [value, setValue] = useState(initialValue);
Enter fullscreen mode Exit fullscreen mode
  • value — the current state.
  • setValue — enqueues a state update and triggers a re-render when appropriate.
  • initialValue — the starting value for the first render (can be a value or a lazy initializer).

Simple example

const [isDarkMode, setIsDarkMode] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Calling setIsDarkMode(true) schedules a re-render where isDarkMode === true.


Lazy initializers (when the initial setup is expensive)

If computing the initial state is costly, pass a function to useState so the work runs only once:

function expensiveInit() {
  // heavy work
  return computeBigInitialValue();
}

const [data, setData] = useState(() => expensiveInit());
Enter fullscreen mode Exit fullscreen mode

Practical example: reading from localStorage only once on mount

const [settings, setSettings] = useState(() =>
  JSON.parse(localStorage.getItem('settings') || '{}')
);
Enter fullscreen mode Exit fullscreen mode

Using the function form prevents localStorage reads on every render — useful when the initial value touches I/O or expensive computation.


Initializing state from props — the common pitfall & pattern

A common trap is this:

const [value, setValue] = useState(props.initialValue);
Enter fullscreen mode Exit fullscreen mode

That initialValue is only used on mount. It does not automatically track later prop changes.

If you need the local state to follow prop updates, do it explicitly:

function Component({ initialValue }) {
  const [value, setValue] = useState(initialValue);

  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: only sync props into state when the component needs a local, editable copy (for example, a form input that starts from a prop). If you just need to reflect the prop, keep props as the single source of truth.


Here’s the merged and tightened version that removes the repetition while keeping all the important ideas, examples, and batching context:


Updating State: Direct Value vs Updater Function

When you update state, you can pass either a direct value or a function (called an updater function) to the setter returned by useState.

Direct value — replaces the state with a new value:

setCount(count + 1);
Enter fullscreen mode Exit fullscreen mode

Updater function — receives the latest committed state and returns the new value:

setCount(prev => prev + 1);
Enter fullscreen mode Exit fullscreen mode

When to use which

  • Direct value — fine when the new value does not depend on the old one (e.g., setTheme('dark')).
  • Updater functionalways use this when the new value depends on the previous state. This avoids subtle bugs, especially because React may batch multiple updates before re-rendering.

Many React developers learn this the hard way. Let’s save you some retries.


Batching & “are state updates synchronous?”

React batches multiple state updates that happen in the same event or async callback to improve performance. This means updates don’t happen immediately — they’re scheduled and applied together just before React re-renders.

Example that surprises beginners:

function Example() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // Logs the old value — updates are still pending
  }

  return <button onClick={handleClick}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Both calls above read the same stale count value from the current render, so the state only increments once.


Fix: use the updater form so each call works with the latest committed state — even during batching:

setCount(prev => prev + 1);
setCount(prev => prev + 1);
Enter fullscreen mode Exit fullscreen mode

Now both increments apply correctly because each updater runs against the newest state snapshot React has committed.


Why batching changes the outcome

React batches multiple state updates that happen in the same synchronous event (like a button click) into one re-render for performance.
This means your component function doesn’t run in between those updates — all the updates share the same render scope.


Direct value form — why it “fails” here

Render 1 starts: count = 0

setCount(count + 1)   // schedules: "set count to 1" (based on 0)
setCount(count + 1)   // schedules: "set count to 1" (still based on 0)

React batches updates  last one wins  count = 1

Single re-render happens (starts from 1)
Enter fullscreen mode Exit fullscreen mode

Problem: Both calls saw count = 0 because React didn’t re-run your component between them.


Updater function form — why it works

Render 1 starts: count = 0

setCount(prev => prev + 1)  // schedules: prev = 0 → 1
setCount(prev => prev + 1)  // schedules: prev = 1 → 2

React batches updates  applies them in sequence  count = 2

Single re-render happens (starts from 2)
Enter fullscreen mode Exit fullscreen mode

Fix: The updater function receives the latest committed value in the batch, so each update builds on the result of the last.


Key takeaway:

In a batched update, multiple setState calls share the same render’s variables. If you need each call to use the latest value, use the updater function.


Stale closures — the sneaky bug that looks random

When functions capture variables from an earlier render, they hold onto that old snapshot. That’s a stale closure. It often shows up with timers, subscriptions, or long-lived callbacks.

Broken example — a classic stale-closure timer

function TimerBroken() {
  const [count, setCount] = useState(0);

  function start() {
    setInterval(() => {
      // This callback *captures* `count` from the render when start() ran.
      setCount(count + 1);
    }, 1000);
  }

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={start}>Start</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

What happens: the interval callback keeps using the old count value it captured, so the counter behavior is wrong (or freezes). This looks random because it depends on when start() was called relative to renders.

Safer patterns — avoid stale closures

  1. Use the functional updater inside callbacks so you always operate on the latest state:
setInterval(() => {
  setCount(prev => prev + 1);
}, 1000);
Enter fullscreen mode Exit fullscreen mode
  1. Store the interval id in a ref and clean it up so you don’t leak timers across mounts/unmounts.

Don't worry about what ref is at the moment, it would be fully explained in later article. For now just read through it at ease. It's primarily for back reference when you need it :)

Correct, robust Timer

import { useState, useRef, useEffect } from 'react';

function Timer() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    if (intervalRef.current) return; // already running
    intervalRef.current = setInterval(() => {
      setCount(prev => prev + 1); // functional updater avoids stale closures
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  useEffect(() => {
    return () => {
      // cleanup on unmount
      clearInterval(intervalRef.current);
    };
  }, []);

  return (
    <>
      <div>Count: {count}</div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this is good:

  • useRef holds the interval id without causing re-renders.
  • setCount(prev => prev + 1) always uses the latest state.
  • useEffect cleanup prevents leaks if the component unmounts.

I would highly encourage you to watch this short video where I explain closures concept properly. (Note channel has dedicated React debounce video as well but this one explains concept of closure properly)


Lifting state up — sharing data cleanly

Imagine you have two volume sliders in your app — one in the header, one in the footer. They’re supposed to control the same volume level.

If each slider keeps its own useState, they’ll quickly get out of sync: moving the header slider won’t move the footer one, and vice versa.

The fix is to lift the state up — move that volume value into the nearest common ancestor of both sliders. That ancestor becomes the single source of truth:

  • It owns the actual state.
  • It passes the value down to each slider via props.
  • It passes an update function down so sliders can request changes.

Example — two sliders, one volume level

Parent (state owner):

function App() {
  const [volume, setVolume] = useState(50);

  return (
    <>
      <VolumeSlider value={volume} onChange={setVolume} />
      <VolumeSlider value={volume} onChange={setVolume} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Child (controlled by parent):

function VolumeSlider({ value, onChange }) {
  return (
    <input
      type="range"
      min="0"
      max="100"
      value={value}
      onChange={e => onChange(Number(e.target.value))}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. One owner: App holds the volume state — there’s no duplication.
  2. Props down: Both VolumeSlider components read value from App.
  3. Callbacks up: When a slider changes, it calls onChange, which updates App’s state.
  4. Always in sync: Updating the parent’s state re-renders both sliders with the new volume.

Why it matters:
This “data down, actions up” approach makes synchronization predictable and avoids subtle bugs from mismatched state.

This keeps a single source of truth (Parent) and avoids duplicated state. If later you need to minimize re-renders, you can memoize Counter or use callbacks (with care — only when necessary). (And yes this will be covered later in the series)


Controlled vs Uncontrolled inputs — pick the right tool

When working with form inputs in React, you have two main ways to manage their values:

  1. Controlled — React keeps the value in state.
  2. Uncontrolled — The DOM keeps the value, and you read it only when needed.

Which you choose affects how you handle validation, synchronization, and performance.


Controlled inputs — React owns the value

In a controlled input, the form element’s value is driven entirely by React state. Every change triggers an onChange event, which updates state, and that state sets the value prop.

function NameForm() {
  const [name, setName] = useState('');

  return (
    <input
      value={name}
      onChange={e => setName(e.target.value)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Single source of truth in React.
  • Easy to validate and transform input as the user types.
  • Other components can react instantly to changes.

Cons:

  • Can cause more re-renders for very fast, continuous input (usually negligible).

Uncontrolled inputs — the DOM owns the value

In an uncontrolled input, you let the DOM manage the element’s value. React does not track it in state. You read it only when necessary, usually via a ref.

import { useRef } from 'react';

function NameFormUncontrolled() {
  const inputRef = useRef();

  function handleSubmit() {
    const value = inputRef.current.value;
    // use value
  }

  return (
    <>
      <input defaultValue="start" ref={inputRef} />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Simpler for quick, standalone form fields.
  • Fewer re-renders during typing.

Cons:

  • Harder to validate or modify while typing.
  • State in the DOM may fall out of sync with other UI state.

When to use which input type

  • Use controlled inputs when you need live validation, dependent UI updates, or consistent state across components.
  • Use uncontrolled inputs for quick, isolated form fields where you only care about the value at submit time.

Object identity & Object.is — why copies matter (concrete example)

React compares new and previous state for equality using an Object.is-like check. That’s why object/array updates usually mean creating a new reference.

Concrete examples

Object.is(1, 1);              // true
Object.is('a', 'a');          // true

const o = {};
Object.is(o, o);              // true

Object.is({}, {});            // false  — two distinct empty objects
Enter fullscreen mode Exit fullscreen mode

So:

// This does NOT trigger a re-render if `obj` is the same reference:
setObj(obj);

// But this WILL (new reference):
setObj(prev => ({ ...prev, x: 1 }));
Enter fullscreen mode Exit fullscreen mode

When you intend to change an object or array stored in state, return a new object/array so React sees a different reference and can re-render.


State Management — Rules You’ll Actually Use

Think of these as your React state “house rules” — simple to remember, hard to regret:

  1. Never set state during render
    Calling setValue(...) directly in the component body will cause infinite loops.
    ➜ Use event handlers, effects, or callbacks instead.

  2. Use functional updaters when new state depends on old
    Example: setCount(prev => prev + 1) avoids batching and stale closure issues.

  3. Avoid stale closures
    Long-lived callbacks (timers, subscriptions) should use updaters, refs, and cleanup functions in useEffect.

  4. Lift state up when multiple components need it
    Keep a single source of truth in the nearest common ancestor; pass data down and events up.

  5. Prefer controlled inputs for most forms
    They make validation, syncing, and UI updates predictable. Use uncontrolled inputs only for isolated, simple fields.

  6. Always create new references for objects/arrays
    React detects changes using Object.is. Mutating in place won’t trigger a re-render.


Pitfalls to Watch For (Real Bugs You’ll See)

These are not theoretical — they happen to everyone at some point.

1. Over-storing state (too many bits)

Don’t store data that can be derived from props or other state — compute it instead.

Unnecessary state:

const [total, setTotal] = useState(
  items.reduce((s, i) => s + i.price, 0)
);
Enter fullscreen mode Exit fullscreen mode

Better:

const total = items.reduce((s, i) => s + i.price, 0);
Enter fullscreen mode Exit fullscreen mode

2. Too coarse vs too fine granularity

  • Too coarse: One giant state object → unrelated updates trigger unnecessary re-renders.
  • Too fine: Dozens of tiny states → noisy code. Rule: Group values that change together; split ones that change independently.

Example — split when updates are independent:

const [name, setName] = useState('');
const [email, setEmail] = useState('');
Enter fullscreen mode Exit fullscreen mode

3. Overusing useMemo / useCallback

They’re performance tools, not lucky charms. Use them after you’ve identified slow renders, not preemptively.
Clear, maintainable code beats micro-optimizations.

4 Expensive render due to derived computation

Problem:

function App({ items }) {
  const total = items.reduce((s, i) => s + i.price, 0); // expensive each render
  return <BigList items={items} total={total} />;
}
Enter fullscreen mode Exit fullscreen mode

Fix: memoize the expensive compute (only if profiling shows it matters):

const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
Enter fullscreen mode Exit fullscreen mode

Performance — Practical Wins

Avoid needless re-renders

  • Lift state to the nearest common ancestor that needs it.
  • Memoize pure child components with React.memo when props rarely change.
  • Keep state local-ish — don’t dump everything into top-level context unless the whole tree needs it.

Example with React.memo:

const Child = React.memo(function Child({ value }) {
  return <div>{value}</div>;
});
Enter fullscreen mode Exit fullscreen mode

This structure gives readers:

  • One rule section (quick reference, minimal repetition).
  • Pitfalls section (concrete examples for the most common mistakes).
  • Performance tips (only once, no duplicates from rules).

Measure before optimizing

Use the React DevTools Profiler to find hot paths. Optimize only what matters.

Use useReducer for complex state transitions

Just like useRef, useReducer has its own dedicated article later in the series.

When updates are many, interdependent, or you need clearer update logic, prefer useReducer. It centralizes transitions and can make code easier to test.

const [state, dispatch] = useReducer(reducer, initialState);
Enter fullscreen mode Exit fullscreen mode

Wrap-up checklist (what to ship)

  • Prefer useState for local UI state (toggles, input values, small counters).
  • Use updater functions (setCount(prev => …)) whenever new state depends on previous state.
  • Avoid storing derived values — compute or memoize them.
  • Lift state up for shared data; split state when independent updates cause many renders.
  • Use useReducer for complex transitions and React.memo for stable child components.

Final thought

useState is simple but subtle. Treat it like a Swiss Army knife: perfect for a lot of daily UI tasks, but know when you need a screwdriver (useReducer), a file (useMemo), or a magnifier (profiler). React 19 adds nicer tools around the edges — optimistic updates, actions, and better coordination — but the basic principles (state → render, identity matters, cleanup matters) stay the same. It would all make sense as you progress through the series.

Next up

Remember Virtual DOM, React's secret weapon for optimal performance? Now you have all the concepts to go over how React renders the UI, and how state plays it's role in the process. The next article stiches it all together.

How React Renders the UI


Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome

Top comments (0)