TUTORIAL

React Hooks Part 1: Managing State with useState

Learn how useState lets functional components hold and update local state. We cover the basics, the callback pattern to avoid race conditions, and object/array state management.


SERIES

React Hooks: A Complete Guide

A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.

Table of Contents

What is useState?

Before Hooks, only class components could hold local state. The useState hook changes that — any functional component can now own state with a single import.

JSX
import { useState } from 'react'

useState accepts one argument — the initial state value — and returns a two-element array: the current value and a setter function.

JSX
const [count, setCount] = useState(0)

A minimal counter

JSX
import { useState } from 'react'

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>−</button>
    </div>
  )
}

Every time you call setCount, React schedules a re-render and the component displays the new value.

The callback pattern — avoid stale closures

If your new value depends on the previous one, don't read count directly — pass a callback instead:

JSX
// Bad: can produce wrong results under rapid updates
setCount(count + 1)

// Good: always receives the latest committed value
setCount(prev => prev + 1)

React batches state updates in event handlers, so the first form might reference a stale snapshot of count. The callback form is immune to this.

Object state

When your state is an object you must spread the existing fields — useState does not shallow-merge like class-based this.setState.

JSX
const [user, setUser] = useState({ name: '', email: '' })

const handleName  = e => setUser(prev => ({ ...prev, name: e.target.value }))
const handleEmail = e => setUser(prev => ({ ...prev, email: e.target.value }))

Forgetting the spread silently drops every field you didn't mention — the single most common useState bug.

Array state

Same principle: never mutate in place.

JSX
const [items, setItems] = useState([])

const addItem    = item => setItems(prev => [...prev, item])
const removeItem = id   => setItems(prev => prev.filter(i => i.id !== id))

Lazy initialisation

If your initial value is expensive to compute, pass a function so it only runs once:

JSX
// Runs parseHeavyData() on every render
const [data, setData] = useState(parseHeavyData(rawInput))

// Function is called only during the first render
const [data, setData] = useState(() => parseHeavyData(rawInput))

Key takeaways

  • Destructure into [value, setValue] — naming is up to you.
  • Use the callback form (prev => …) whenever new state depends on the old one.
  • Always spread when updating object/array state.
  • Pass a function for expensive initial values.

Next up in the series: useEffect — running side effects after render.


Was this article helpful?

w

webencher Editorial

Software engineers and technical writers with 10+ years of combined experience in algorithms, systems design, and web development. Every article is reviewed for accuracy, depth, and practical applicability.

More by this author →