Skip to content

State with useState (and when useReducer shows up) ​

In Vue you poke a ref. In React you call a setter and let the next render pick it up.

useState - the daily driver ​

tsx
import { useState } from 'react'

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

  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  )
}

Comments for the code above:

  • useState(0) returns [value, setter] - like ref + writing to .value, but split
  • Calling setCount schedules a re-render - you don't mutate count
  • Initial state argument runs on first mount (pass a function if it's expensive: useState(() => bigCompute()))

Don't mutate - replace ​

Vue reactive objects forgive a lot of in-place mutation. React state wants a new value:

tsx
const [todos, setTodos] = useState<{ id: number; text: string }[]>([])

// add
setTodos([...todos, { id: Date.now(), text: 'Ship it' }])

// update one
setTodos(todos.map((t) => (t.id === id ? { ...t, text: 'Done' } : t)))

// remove
setTodos(todos.filter((t) => t.id !== id))

As you can see - spread / map / filter. Same immutability story as Redux-ish Vue code, just everywhere.

Functional updates ​

If the next value depends on the previous, prefer the callback form - especially in effects or rapid clicks:

tsx
setCount((c) => c + 1)

Why: React may batch updates. Reading count from the closure can be stale. The callback gets the latest queued value.

Multiple pieces of state ​

tsx
const [name, setName] = useState('')
const [age, setAge] = useState(0)

Or one object (closer to reactive):

tsx
const [form, setForm] = useState({ name: '', age: 0 })

setForm({ ...form, name: 'Ada' })

I've found: separate useState calls for unrelated things, one object when fields travel together (forms).

Derived state - just compute it ​

Vue computed:

ts
const fullName = computed(() => `${first.value} ${last.value}`)

React - during render:

tsx
const fullName = `${first} ${last}`

Don't put that in useEffect + useState. That's a classic "I fought the framework" move. If it's pure derivation, compute it.

useReducer - when updates get spicy ​

If you have many related transitions (like a mini state machine), useReducer reads nicer:

tsx
type State = { count: number }
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'set'; value: number }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc':
      return { count: state.count + 1 }
    case 'dec':
      return { count: state.count - 1 }
    case 'set':
      return { count: action.value }
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 })
  return (
    <>
      <p>{state.count}</p>
      <button type="button" onClick={() => dispatch({ type: 'inc' })}>+</button>
    </>
  )
}

Comments for the code above:

  • Same idea as a tiny Vuex/Pinia slice without the global store
  • I reach for it when setState callbacks get nested and sad

Next: effects - the chapter where Vue watch instincts both help and betray you.

Enjoy!