How to buildOctober 11, 20269 min read
ReactStep 3 of 8

React: state and useState

In short

State is a component's memory between renders: useState returns the current value and a function that stores a new one and asks React to re-render the component. Within a single render the value is a fixed snapshot, so updates based on the previous value are written as a function: setCount(c => c + 1). Objects and arrays in state are never mutated but replaced with new ones, and state shared by several components is lifted up to their closest common parent.

TutorialLevel: BeginnerReact HooksPractice: ~45–90 min

After this you will be able to

  • Explain why a regular variable doesn't trigger a re-render and why state is a snapshot
  • Update state with a setX(prev => …) function when the new value depends on the old one
  • Change objects and arrays in state without mutating them: spread, map, filter
  • Build a controlled input and lift shared state up to a parent, deriving values during render

Verified with

React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3@testing-library/user-event 14.6.7Node.js 24 LTS (24.18.0)

Checked against docs: October 11, 2026

Contents
  1. 01State as a component's memory
  2. 02State as a snapshot
  3. 03Updating based on the previous value
  4. 04Objects and arrays: updates without mutation
  5. 05Controlled inputs
  6. 06Lifting state up to the common parent
  7. 07Common mistakes, testing and practice

State as a component's memory

In the previous lesson the catalogue rendered whatever came in through props and could do nothing else. Now the UI has to respond to actions: “Add to cart” adds an item, “+” and “−” change the quantity, and a promo code field applies a discount. For that, a component needs memory: state.

A first attempt is a regular variable. Its value changes, but the screen stays the same.

src/components/Counter.tsx
import { useState } from 'react'

export function BrokenCounter() {
  let count = 0

  function handleClick() {
    count = count + 1 // the value changes, but React doesn't know about it
  }

  return <button onClick={handleClick}>Broken counter: {count}</button>
}

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

  function addThreeWrong() {
    // in this render count is a snapshot, say 0: “replace with 1” three times
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
  }

  function addThree() {
    // updater functions are queued: 0 → 1 → 2 → 3
    setCount((c) => c + 1)
    setCount((c) => c + 1)
    setCount((c) => c + 1)
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={addThreeWrong}>+3 with snapshot</button>
      <button onClick={addThree}>+3 with updater</button>
    </div>
  )
}
Practice counters: BrokenCounter shows what not to do, Counter uses useState.

The React docs give two reasons why BrokenCounter doesn't work. First, local variables don't persist between renders: the next time the function runs, count is 0 again. Second, changing a local variable doesn't trigger a render: React simply doesn't know the data has changed.

The useState Hook solves both: it provides a state variable that persists between renders and a setter function that updates the value and asks React to render the component again. const [count, setCount] = useState(0) destructures a two-item array; 0 is the initial value. TypeScript infers the type from it: number here.

  • Hooks are functions whose names start with use. By React's rules, you call them only at the top level of a component or of your own Hook, never inside conditions, loops or nested functions.
  • State belongs to a component instance. Render <Counter /> twice and each copy gets its own independent value.
  • State is private: unlike props, the parent can't change it directly.

State as a snapshot

The most surprising thing about useState for newcomers: calling setCount doesn't change the count variable you already have. The docs put it this way: state behaves more like a snapshot. The setter asks React for a new render, and count has its new value in that render. Within the current render, including all of its event handlers, the value doesn't change.

You can see this with the “+3 with snapshot” button. The handler calls setCount(count + 1) three times, but in all three calls count is this render's value, say 0. That's “replace with 1” three times, so the counter goes up by one, not three. The test at the end of the lesson checks this.

setCount(count + 1) changes count immediately

What actually happens
count stays the same in this render; the new value arrives with the next render

console.log(count) after the setter shows the new value

What actually happens
it shows the old one. If you need the new value, store it in a variable: const next = count + 1; setCount(next)

Three setCount(count + 1) calls add 3

What actually happens
they add 1: all three read the same snapshot

A setTimeout in the handler sees the latest value

What actually happens
it sees the value from the render in which the handler was created
Based on react.dev: State as a Snapshot and useState → Troubleshooting.

Updating based on the previous value

If the new value depends on the previous one, pass the setter an updater function instead of a value: setCount((c) => c + 1). React queues these functions and, once the handler finishes, runs them in order, passing each one the result of the previous one. That's why the “+3 with updater” button really adds three.

The docs explain the queue like this: React waits until all the code in the event handler has run before processing state updates. This is called batching. Values and functions can be mixed in the queue:

setCount(count + 5)

What gets queued
“replace with 5”
Result with count = 0
5

setCount((c) => c + 1)

What gets queued
the function c => c + 1
Result with count = 0
5 + 1 = 6

setCount(42) after them

What gets queued
“replace with 42”, discarding everything before it
Result with count = 0
42
Example from react.dev: Queueing a Series of State Updates.
  • By convention, the updater's argument is named after the first letter of the state variable (c for count) or the full name with a prev prefix: setCart((prev) => …).
  • An updater must be pure: it should only compute the new value from the old one. In development under StrictMode, React calls it twice to surface accidental side effects.
  • Don't confuse passing a function with calling it: useState(createInitialCart) runs the initializer once, while useState(createInitialCart()) runs it on every render and throws the result away.

Objects and arrays: updates without mutation

State can hold an object or an array. The rule from the React docs: treat anything you put into state as read-only. You can't change an object or array in place; you create a new one and pass it to the setter. Here's what the mistake looks like:

src/App.tsx
  function addToCart(id: string) {
    const line = cart.find((l) => l.id === id)
    if (line) {
      line.qty += 1 // mutates an object that lives in state
    } else {
      cart.push({ id, qty: 1 }) // mutates the state array itself
    }
    setCart(cart) // same array: React compares with Object.is and skips the render
  }
Don't do this. A replacement addToCart, for the experiment only.

The data does change, but setCart(cart) receives the very same array. React compares the old and new values with Object.is, sees they're identical and skips the render. We checked this with a test: after the click, the screen still shows “Your cart is empty” and a total of 0 ₸. Worse, the mutation also changes the “old” snapshots that other code may rely on.

src/App.tsx
  function addToCart(id: string) {
    setCart((prev) =>
      prev.some((line) => line.id === id)
        ? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
        : [...prev, { id, qty: 1 }],
    )
  }
The right way: a new array via map or spread, and new objects for the changed lines.

Add

Avoid (mutates)
push, unshift
Use (returns something new)
[...arr, item], concat

Remove

Avoid (mutates)
pop, shift, splice
Use (returns something new)
filter, slice

Replace or change an item

Avoid (mutates)
arr[i] = …, splice
Use (returns something new)
map

Sort or reverse

Avoid (mutates)
sort, reverse on the original array
Use (returns something new)
copy first: [...arr].sort()

Change an object field

Avoid (mutates)
obj.qty = 2
Use (returns something new)
{ ...obj, qty: 2 }
Table based on react.dev: Updating Arrays in State.

The ... spread copies only one level deep. To update a nested field, copy every level from the changed field upwards: { ...order, delivery: { ...order.delivery, fee: 0 } }. For deep nesting, the docs suggest two options: flatten the state structure, or use the Immer library, which lets you write “mutating” code and creates the copies for you.

Controlled inputs

The promo code field is a controlled input: its value is driven by state, not by the browser. The pairing is always the same: value from state and an onChange that synchronously stores the new value.

src/App.tsx
<input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />
A fragment of App.tsx: the user types autumn, and both the field and the state end up with AUTUMN.

This lets you not only store input but normalise it on the spot: trim spaces, upper-case it, drop unwanted characters. For checkboxes, use checked instead of value and read e.target.checked.

  • value without onChange makes the field impossible to type into: React restores the state value after every keystroke. In development it warns about this in the console. If you only need an initial value, use defaultValue.
  • value must not be undefined or null, or React treats the field as uncontrolled and warns when a string shows up later. The initial value for an empty field is ''; for API data use value={name ?? ''}.
  • An input's value is always a string, even with type="number". Keep the string and convert it when calculating: Number(qty).

Lifting state up to the common parent

The menu and the cart are separate components but work with the same data: a button in the menu changes the cart's contents. If each kept its own copy, they'd soon fall out of sync. The React docs describe the solution, lifting state up: remove the state from the child components, move it to their closest common parent, and pass it down through props together with event handlers.

src/data/menu.ts
export interface MenuItem {
  id: string
  name: string
  price: number // tenge
}

export interface CartLine {
  id: string // a reference to a MenuItem, not a copy of it
  qty: number
}

export const menu: MenuItem[] = [
  { id: 'cappuccino', name: 'Cappuccino', price: 1600 },
  { id: 'latte', name: 'Latte', price: 1800 },
  { id: 'cheesecake', name: 'Cheesecake', price: 2400 },
]

export const PROMO_CODE = 'AUTUMN'
export const PROMO_PERCENT = 10
src/components/Menu.tsx
import type { MenuItem } from '../data/menu.ts'

interface MenuProps {
  items: MenuItem[]
  onAdd: (id: string) => void
}

export function Menu({ items, onAdd }: MenuProps) {
  return (
    <ul className="menu">
      {items.map((item) => (
        <li key={item.id}>
          {item.name} — {item.price} ₸{' '}
          <button onClick={() => onAdd(item.id)}>Add to cart</button>
        </li>
      ))}
    </ul>
  )
}
src/components/Cart.tsx
import type { CartLine, MenuItem } from '../data/menu.ts'

interface CartProps {
  lines: CartLine[]
  items: MenuItem[]
  onChangeQty: (id: string, delta: number) => void
  onRemove: (id: string) => void
}

export function Cart({ lines, items, onChangeQty, onRemove }: CartProps) {
  if (lines.length === 0) {
    return <p>Your cart is empty</p>
  }

  return (
    <ul className="cart">
      {lines.map((line) => {
        const item = items.find((i) => i.id === line.id)
        if (!item) return null
        return (
          <li key={line.id}>
            {item.name}
            <button aria-label={`Fewer: ${item.name}`} onClick={() => onChangeQty(line.id, -1)}>
              −
            </button>
            <span aria-label={`Quantity: ${item.name}`}>{line.qty}</span>
            <button aria-label={`More: ${item.name}`} onClick={() => onChangeQty(line.id, 1)}>
              +
            </button>
            {item.price * line.qty} ₸
            <button onClick={() => onRemove(line.id)}>Remove</button>
          </li>
        )
      })}
    </ul>
  )
}
src/App.tsx
import { useState } from 'react'
import { Cart } from './components/Cart.tsx'
import { Menu } from './components/Menu.tsx'
import { menu, PROMO_CODE, PROMO_PERCENT, type CartLine } from './data/menu.ts'

function App() {
  // the state lives in the closest common parent of Menu and Cart
  const [cart, setCart] = useState<CartLine[]>([])
  const [promo, setPromo] = useState('')

  function addToCart(id: string) {
    setCart((prev) =>
      prev.some((line) => line.id === id)
        ? prev.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))
        : [...prev, { id, qty: 1 }],
    )
  }

  function changeQty(id: string, delta: number) {
    setCart((prev) =>
      prev
        .map((line) => (line.id === id ? { ...line, qty: line.qty + delta } : line))
        .filter((line) => line.qty > 0),
    )
  }

  function removeLine(id: string) {
    setCart((prev) => prev.filter((line) => line.id !== id))
  }

  // derived values are calculated during render, not stored in state
  const subtotal = cart.reduce((sum, line) => {
    const item = menu.find((i) => i.id === line.id)
    return item ? sum + item.price * line.qty : sum
  }, 0)
  const promoApplied = promo === PROMO_CODE
  const discount = promoApplied ? Math.round((subtotal * PROMO_PERCENT) / 100) : 0
  const total = subtotal - discount

  return (
    <main>
      <h1>Coffee shop</h1>
      <Menu items={menu} onAdd={addToCart} />

      <h2>Cart</h2>
      <Cart lines={cart} items={menu} onChangeQty={changeQty} onRemove={removeLine} />

      <label>
        Promo code{' '}
        <input value={promo} onChange={(e) => setPromo(e.target.value.trim().toUpperCase())} />
      </label>
      {promoApplied && <p>Discount {PROMO_PERCENT}%: −{discount} ₸</p>}
      <p>
        <strong>Total: {total} ₸</strong>
      </p>
    </main>
  )
}

export default App
  • A single source of truth: each piece of state has one owning component. App owns the cart; Menu and Cart only display data and report actions through onAdd, onChangeQty and onRemove.
  • Menu and Cart are controlled components: their behaviour is fully driven by props. The docs call a component whose local state the parent can't influence uncontrolled.
  • The cart stores an id and a quantity, not copies of the items. The docs recommend storing the selected item's ID rather than the object itself: if a price changes in the menu, the cart shows the new one immediately.
  • subtotal, discount and total aren't in state: they can be calculated from cart and promo on every render. Redundant state that has to be synced by hand is a common source of bugs.

Common mistakes, testing and practice

Mutating an object or array

Symptom
the data changed, the screen didn't
Fix
create a new object: spread, map, filter

Stale value

Symptom
+3 adds 1; console.log after the setter shows the old value
Fix
an updater setX(prev => …); keep the new value in a separate variable

onClick={handleClick()}

Symptom
a Too many re-renders error: the setter runs during render
Fix
pass the function: onClick={handleClick} or onClick={() => handleClick(id)}

Redundant state

Symptom
the total disagrees with the cart
Fix
calculate derived values during render

Copying props into state

Symptom
useState(props.color) ignores new props
Fix
use the prop directly; if you only need the initial value, name the prop initialColor
Symptoms and fixes from the Troubleshooting and Choosing the State Structure pages on react.dev.

Let's check the behaviour with tests. The project is created as in the previous lesson, with the same Vitest configuration and src/test/setup.ts. You'll also need @testing-library/user-event, which simulates clicks and typing the way a user does.

bash
npm create vite@latest coffee-cart -- --template react-ts
cd coffee-cart
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event
src/App.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { BrokenCounter, Counter } from './components/Counter.tsx'

describe('counters', () => {
  it('a regular variable does not trigger a re-render', async () => {
    const user = userEvent.setup()
    render(<BrokenCounter />)
    await user.click(screen.getByRole('button'))
    expect(screen.getByRole('button').textContent).toBe('Broken counter: 0')
  })

  it('state is a snapshot: +3 with snapshot adds 1, with updater adds 3', async () => {
    const user = userEvent.setup()
    render(<Counter />)
    await user.click(screen.getByRole('button', { name: '+3 with snapshot' }))
    expect(screen.getByText('Count: 1')).toBeTruthy()
    await user.click(screen.getByRole('button', { name: '+3 with updater' }))
    expect(screen.getByText('Count: 4')).toBeTruthy()
  })
})

describe('cart', () => {
  it('adds, changes quantity, removes and totals', async () => {
    const user = userEvent.setup()
    render(<App />)
    const addButtons = screen.getAllByRole('button', { name: 'Add to cart' })

    await user.click(addButtons[0]) // Cappuccino
    await user.click(addButtons[0])
    await user.click(addButtons[2]) // Cheesecake
    expect(screen.getByLabelText('Quantity: Cappuccino').textContent).toBe('2')
    expect(screen.getByText('Total: 5600 ₸')).toBeTruthy()

    await user.click(screen.getByRole('button', { name: 'Fewer: Cheesecake' }))
    expect(screen.queryByLabelText('Quantity: Cheesecake')).toBeNull()
    expect(screen.getByText('Total: 3200 ₸')).toBeTruthy()

    await user.click(screen.getByRole('button', { name: 'Remove' }))
    expect(screen.getByText('Your cart is empty')).toBeTruthy()
    expect(screen.getByText('Total: 0 ₸')).toBeTruthy()
  })

  it('controlled input: the promo code is upper-cased and applies a discount', async () => {
    const user = userEvent.setup()
    render(<App />)
    await user.click(screen.getAllByRole('button', { name: 'Add to cart' })[1]) // Latte
    await user.type(screen.getByLabelText('Promo code'), 'autumn')
    expect(screen.getByLabelText<HTMLInputElement>('Promo code').value).toBe('AUTUMN')
    expect(screen.getByText('Discount 10%: −180 ₸')).toBeTruthy()
    expect(screen.getByText('Total: 1620 ₸')).toBeTruthy()
  })
})
terminal
$ npm test

> coffee-cart@0.0.0 test
> vitest run

 RUN  v5.0.3 …/coffee-cart

 Test Files  1 passed (1)
      Tests  4 passed (4)
npm run build (type check and bundle) passes without errors.

State handles what happens in response to user actions. Sometimes, though, a component needs to synchronise with something outside React: a timer, a subscription, a server. That's the subject of the next lesson, useEffect, where we'll also look at when you don't need an Effect at all.

How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.