TBTinkerBytes

Understanding useState — with a live demo

A deep dive into React's useState hook, with an interactive counter you can play with right here in the page.

·3 min read

useState is the most fundamental React hook. You've used it hundreds of times. But do you know exactly when re-renders happen, or how React batches state updates?

Let's explore — with a live demo you can interact with directly.

The demo

Here's a simple counter built with useState. Go ahead and click the buttons:

Live Demo — useState counter
0

This is a live React component embedded in an MDX post

That component is a real React component, rendered live on this page — not a screenshot, not an iframe.

How it works

The counter uses a basic useState pattern:

'use client'
 
import { useState } from 'react'
 
export function CounterDemo() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c - 1)}>−</button>
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  )
}

Key things to know about useState

1. The updater function

Notice we're using setCount(c => c + 1) rather than setCount(count + 1). This is the functional update form and it matters when updates are batched:

// ⚠️ Might not work as expected in concurrent features
setCount(count + 1)
setCount(count + 1) // still count + 1, not count + 2
 
// ✅ Always correct — reads the latest queued state
setCount(c => c + 1)
setCount(c => c + 1) // correctly becomes count + 2

2. State updates trigger re-renders

Every call to setCount schedules a re-render. React batches multiple setState calls that happen in the same event handler — since React 18, this also applies to async contexts.

3. State is per-component-instance

Each instance of CounterDemo has its own isolated state. If you rendered two <CounterDemo /> components on the same page, their counts would be completely independent.

When to reach for useReducer instead

useState is great for simple, independent values. Reach for useReducer when:

  • State transitions are complex
  • Next state depends on previous state in non-trivial ways
  • You have multiple related state values that update together
type State = { count: number; history: number[] }
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1, history: [...state.history, state.count + 1] }
    case 'decrement':
      return { count: state.count - 1, history: [...state.history, state.count - 1] }
    case 'reset':
      return { count: 0, history: [] }
  }
}

More interactive posts coming soon — each one with something you can poke at directly in the page.