Part 2: State and events
Props let components receive data. State lets components remember data that changes over time. This part introduces useState, event handling, and the pattern that makes sibling components share data: lifting state up.
useState
useState is a hook that returns a value and a setter. When you call the setter, React re-renders the component with the new value.
import { useState } from 'react'
function Counter() { const [count, setCount] = useState(0)
return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(count - 1)}>Decrement</button> <button onClick={() => setCount(0)}>Reset</button> </div> )}The useState(0) call:
- Takes an initial value (
0) - Returns a tuple: the current state value, and a function to update it
- The component re-renders every time
setCountis called with a new value
TypeScript infers the type from the initial value. If you need to start with null and later hold a string:
const [user, setUser] = useState<string | null>(null)State is a snapshot
State values are fixed for a given render. This trips people up when they write:
// Bad: count is stale inside the closuresetCount(count + 1)setCount(count + 1) // still count + 1, not count + 2When the next state depends on the previous, use the functional updater form:
// Good: each call gets the latest valuesetCount(prev => prev + 1)setCount(prev => prev + 1) // count + 2Use the functional form whenever you call setState inside async code, loops, or event batches.
Event handlers
React normalizes browser events into SyntheticEvent objects. The pattern is always: pass a function to an event prop, don’t call it immediately.
// Good: passes the function reference<button onClick={handleClick}>Click me</button>
// Bad: calls the function immediately during render<button onClick={handleClick()}>Click me</button>Common event props:
function EventDemo() { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() console.log('clicked') }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { console.log(e.target.value) }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { console.log('submitted') } }
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() // process form }
return ( <form onSubmit={handleSubmit}> <input onChange={handleChange} onKeyDown={handleKeyDown} /> <button onClick={handleClick} type="submit">Go</button> </form> )}TypeScript’s event types (React.MouseEvent, React.ChangeEvent, etc.) are in @types/react which Vite installs automatically.
Controlled inputs
In React, form inputs are either controlled (React owns the value) or uncontrolled (the DOM owns the value). Prefer controlled inputs: they make validation, transformation, and submission straightforward.
function SearchBox() { const [query, setQuery] = useState('')
return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <p>You typed: {query}</p> <button onClick={() => setQuery('')}>Clear</button> </div> )}The value prop makes React the source of truth. The onChange handler keeps state in sync with what the user types. If you set value without onChange, the input becomes read-only.
Multiple fields
For forms with several fields, use an object in state instead of separate useState calls:
interface FormData { name: string email: string message: string}
function ContactForm() { const [form, setForm] = useState<FormData>({ name: '', email: '', message: '', })
const handleChange = ( e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> ) => { const { name, value } = e.target setForm(prev => ({ ...prev, [name]: value })) }
const handleSubmit = (e: React.FormEvent) => { e.preventDefault() console.log('Submitting:', form) }
return ( <form onSubmit={handleSubmit}> <input name="name" value={form.name} onChange={handleChange} placeholder="Name" /> <input name="email" value={form.email} onChange={handleChange} placeholder="Email" /> <textarea name="message" value={form.message} onChange={handleChange} placeholder="Message" /> <button type="submit">Send</button> </form> )}The [name]: value computed property key means one handleChange handles every field.
State with objects and arrays
When state is an object or array, you must replace the whole value, not mutate it in place. React uses reference equality to detect changes.
// Bad: mutates the existing object, React sees same reference, no re-renderstate.items.push(newItem)setState(state)
// Good: return a new arraysetState(prev => [...prev.items, newItem])A task list with add and toggle:
interface Task { id: number title: string done: boolean}
function TaskApp() { const [tasks, setTasks] = useState<Task[]>([]) const [input, setInput] = useState('')
const addTask = () => { if (!input.trim()) return setTasks(prev => [ ...prev, { id: Date.now(), title: input.trim(), done: false }, ]) setInput('') }
const toggleTask = (id: number) => { setTasks(prev => prev.map(task => task.id === id ? { ...task, done: !task.done } : task ) ) }
const removeTask = (id: number) => { setTasks(prev => prev.filter(task => task.id !== id)) }
return ( <div> <div> <input value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} placeholder="New task" /> <button onClick={addTask}>Add</button> </div> <ul> {tasks.map(task => ( <li key={task.id}> <span style={{ textDecoration: task.done ? 'line-through' : 'none' }} onClick={() => toggleTask(task.id)} > {task.title} </span> <button onClick={() => removeTask(task.id)}>Delete</button> </li> ))} </ul> <p>{tasks.filter(t => !t.done).length} remaining</p> </div> )}Lifting state up
When two sibling components need to share data, move the state to their common parent and pass it down as props.
Consider a filter bar and a list that need to stay in sync:
// Bad: state in FilterBar, but TaskList can't see itfunction FilterBar() { const [filter, setFilter] = useState('all') // stuck here ...}
function TaskList() { // can't access filter from FilterBar}Lift state to the parent:
interface FilterBarProps { filter: string onFilterChange: (filter: string) => void}
function FilterBar({ filter, onFilterChange }: FilterBarProps) { return ( <div> {['all', 'active', 'done'].map(f => ( <button key={f} onClick={() => onFilterChange(f)} style={{ fontWeight: filter === f ? 'bold' : 'normal' }} > {f} </button> ))} </div> )}interface TaskListProps { tasks: Task[] filter: string onToggle: (id: number) => void}
function TaskList({ tasks, filter, onToggle }: TaskListProps) { const visible = tasks.filter(task => { if (filter === 'active') return !task.done if (filter === 'done') return task.done return true })
return ( <ul> {visible.map(task => ( <li key={task.id} onClick={() => onToggle(task.id)}> {task.title} </li> ))} </ul> )}// App.tsx: parent owns the statefunction App() { const [tasks, setTasks] = useState<Task[]>([]) const [filter, setFilter] = useState('all')
const toggleTask = (id: number) => { setTasks(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)) ) }
return ( <div> <FilterBar filter={filter} onFilterChange={setFilter} /> <TaskList tasks={tasks} filter={filter} onToggle={toggleTask} /> </div> )}The parent holds the state; children receive data and callbacks. This is the core React data-flow pattern. Everything flows down; events bubble up through callbacks.
Gotchas at this stage
- Stale closure. A function defined inside a component captures the state value at the time it was created. If you call
setCount(count + 1)inside asetTimeout, thecountit sees may be old. Use the functional updatersetCount(prev => prev + 1). - Don’t set state during render. Calling
setStatedirectly in the function body (not inside a handler or effect) creates an infinite render loop. - Objects and arrays need spreading.
setState({ ...prev, key: newValue }). Forgetting the spread silently drops other fields. - Controlled input with
undefined. Ifvalueisundefined, React treats the input as uncontrolled. Initialize state to'', notundefined.
What’s next
Part 3 covers the broader hooks API: useEffect for side effects (fetching, subscriptions, timers), useRef, useCallback, useMemo, and how to write custom hooks that encapsulate reusable logic.