Must knowHooks · 01

Managing UI State

useState, controlled inputs, deriving values instead of storing them twice, and lazy initial state.

On this page · 8 sections
  1. In one minute
  2. The example: product search with category filters
  3. Reading the first value from the URL
  4. How it works
  5. Common mistakes
  6. Interview Q&A
  7. Related
  8. Sources

In one minute

useState gives a component a value React remembers between renders, plus a function to change it: const [query, setQuery] = useState(''). Calling setQuery doesn’t change query in the running code; it asks React to render again, and in that next render useState returns the new value. Three habits make state code reliable: make inputs controlled (state is written back with value), derive anything you can compute instead of storing it twice, and pass a function to useState when the first value is expensive to compute.

You’ll be able to: wire an input to state correctly, avoid duplicated state, and know when the lazy initializer matters.

onChange only
Search.tsx
1function Search() {2  const [query, setQuery] = useState('')3  return (<>4    <input onChange={e => setQuery(e.target.value)} />5    <input type="checkbox" name="office"6      onChange={e => toggle('office', e.target.checked)} />7    <Results query={query} />8  </>)9}
What you see
lamp
☐ office
☐ audio
Results for “lamp”: 1 product
  1. eventonChange={e => toggle('office', e.target.checked)} />The user typed “lamp”, then ticks office. The checkbox adds the word to the query.
  2. render phaseconst [query, setQuery] = useState('')State changes to "lamp office" and React re-renders.
  3. render phase<input onChange={e => setQuery(e.target.value)} />Nothing tells the input what to show, so it keeps its own text. Recorded: the box still read "lamp" while the results used "lamp office".
  4. <input type="checkbox" name="office"It fails the other way too: typing “audio” didn’t tick the audio checkbox (recorded: audioChecked: false). Two copies of the truth, already out of sync.

0 / 0
A search box plus category checkboxes that edit the same query. An input is controlled when every render tells it what to show (both versions recorded).

The example: product search with category filters

The product store’s search box narrows products by name or category. Ticking a category checkbox adds its name to the query ("lamp office").

First attempt: onChange only

function SearchUncontrolled() {
	const [query, setQuery] = useState('')
	function toggle(category: string, checked: boolean) {
		const words = query.split(' ').filter((w) => w && w !== category)
		setQuery([...words, ...(checked ? [category] : [])].join(' '))
	}
	return (
		<>
			<input id="search" type="search" onChange={(e) => setQuery(e.currentTarget.value)} />
			{categories.map((c) => (
				<label key={c}>
					<input type="checkbox" name={c} onChange={(e) => toggle(c, e.currentTarget.checked)} /> {c}
				</label>
			))}
			<Results query={query} />
		</>
	)
}

The recorder typed “lamp”, ticked office, then replaced the text with “audio”:

{
  "input": "lamp",
  "officeChecked": true,
  "results": "1 products: Desk Lamp"
}
{
  "audioChecked": false,
  "results": "2 products: Wireless Headphones, Bluetooth Speaker"
}

State and screen disagree: the results use "lamp office", but the box still shows "lamp"; and typing “audio” doesn’t tick the audio checkbox. The DOM elements keep their own values, and React only hears about changes.

Controlled and derived

function SearchControlled() {
	const [query, setQuery] = useState('')
	const words = query.split(' ') // derived on every render
	function toggle(category: string, checked: boolean) {
		const rest = words.filter((w) => w && w !== category)
		setQuery([...rest, ...(checked ? [category] : [])].join(' '))
	}
	return (
		<>
			<input id="search" type="search" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
			{categories.map((c) => (
				<label key={c}>
					<input
						type="checkbox"
						name={c}
						checked={words.includes(c)}
						onChange={(e) => toggle(c, e.currentTarget.checked)}
					/>{' '}
					{c}
				</label>
			))}
			<Results query={query} />
		</>
	)
}
{
  "input": "lamp office",
  "officeChecked": true,
  "results": "1 products: Desk Lamp"
}
{
  "audioChecked": true,
  "results": "2 products: Wireless Headphones, Bluetooth Speaker"
}

Two changes fixed both problems:

Reading the first value from the URL

A shared link like ?query=mug should pre-fill the search:

function getQueryParam() {
	log('getQueryParam() runs')
	return new URLSearchParams(window.location.search).get('query') ?? ''
}

function SearchEager() {
	const [query, setQuery] = useState(getQueryParam()) // called on every render
	return <input id="search" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
}

function SearchLazy() {
	const [query, setQuery] = useState(getQueryParam) // React calls it once
	return <input id="search" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
}

The recorder opened ?query=mug and typed “ xl” (three keystrokes, three re-renders). How often getQueryParam ran:

useState(getQueryParam()):

getQueryParam() runs
getQueryParam() runs
getQueryParam() runs
getQueryParam() runs

useState(getQueryParam):

getQueryParam() runs

useState(getQueryParam()) calls the function on every render and throws the result away after the first. Passing the function itself lets React call it once, on mount. That matters for expensive work (parsing localStorage, building a big object); for useState('') or useState(0) it doesn’t.

How it works

Common mistakes

Interview Q&A

What does useState give you?

The current value and a setter. React keeps the value outside the function, so it survives the function being called again on every render; calling the setter schedules a render in which useState returns the new value.

What makes an input "controlled"?

Passing value (or checked) from state together with an onChange that updates that state. Every render writes the state into the DOM, so React is the single source of truth. Recorded: with onChange only, ticking “office” changed the query to "lamp office" while the box still showed "lamp"; with value={query} the box showed "lamp office".

Why derive values instead of storing them in more state?

A second piece of state is a second copy that must be kept in sync by hand. If a value can be computed from existing state or props (words.includes('audio')), compute it during render: it can never be out of date. Recorded: the derived audio checkbox ticked itself when “audio” was typed.

What's the difference between useState(fn()) and useState(fn)?

useState(fn()) calls fn on every render and uses the result only the first time. useState(fn) hands React the function, which it calls once, on mount. Recorded: four calls vs one over the same three keystrokes.

Why can't hooks be called conditionally?

React matches each hook call to its stored state by order. If a hook is skipped on one render, every hook after it gets another hook’s state.

Sources

Primary source: react.dev/reference/react/useState