React / Components and state / Lesson 3
Events and state snapshots
Connect user actions to state updates while reasoning about the values captured by each render.
On this page
Lesson goals
- An event is a deliberate boundary
- Request an update from an interaction
- Keep event logic distinct from synchronization
An event is a deliberate boundary
Pass a function to an event prop such as onClick rather than calling the function during render. The browser interaction invokes that handler later. This separates describing a button from performing its action. Name handlers for the behavior they own and pass callbacks through props when a parent owns the state. A button should have a meaningful accessible name and an explicit type inside a form. React does not remove ordinary event propagation or default browser behavior; stopPropagation and preventDefault solve different problems and should be used only when the interaction requires them.
State belongs to a render snapshot
useState returns the current render’s value and a setter that requests another render. Calling the setter does not rewrite the variable already captured in that handler. Each render receives its own snapshot, so reading count immediately after setCount can still read the old count from that render. This is not a reason to add arbitrary delays or mutate a local variable. Predict the requested next state and the next committed interface separately. A local ordinary variable is recreated during rendering and does not provide persistent component state across renders.
Request an update from an interaction
The first example increments a counter through a native button. A functional updater computes from the queued previous state and avoids depending on a possibly older count captured by the handler. The updater must be pure because React can evaluate it during its coordination and development checks. Do not send a request or modify another object inside it. The displayed number changes after the update is committed. Tests should click the control and wait for the expected visible result rather than asserting an implementation-specific render count or assuming the setter returns a promise to await.
import { useState } from "react";
export default function App() { const [count, setCount] = useState(0); return <main><h1>Counter</h1><p>{count}</p><button type="button" onClick={() => setCount(c => c + 1)}>Add one</button></main>; }
Initial: 0. After one Add one click: 1.
The handler queues a pure update from the prior value. One click changes the visible counter from zero to one.
Compose queued updates correctly
The second example queues three functional increments, producing a net increase of three from one click. In contrast, three setCount(count + 1) calls in the same handler all compute from the same captured count and request the same replacement. React batches updates in supported contexts, but batching does not change JavaScript closure semantics. Keep the updater’s input and returned result distinct, and do not read an unrelated state variable inside it to implement a multi-field invariant. A reducer or a single coherent state value can make such transitions clearer when several fields must change together.
import { useState } from "react";
export default function App() { const [count, setCount] = useState(0); function addThree() { setCount(c => c + 1); setCount(c => c + 1); setCount(c => c + 1); } return <main><h1>Queued updates</h1><p>{count}</p><button type="button" onClick={addThree}>Add three</button></main>; }
Initial: 0. After one Add three click: 3.
Each updater receives the result of the preceding queued update, so one click adds three rather than one.
Keep event logic distinct from synchronization
If an action occurs because a user clicked Purchase, keep the action at that event boundary rather than watching a state flag in an Effect and issuing the purchase indirectly. Effects synchronize external systems with committed state and have different lifecycle behavior. Events can also schedule asynchronous work whose completion needs ownership, cancellation, or stale-result protection. A state snapshot captured before an await remains that snapshot afterward. Test repeated clicks, disabled or pending behavior, and failure outcomes. State updates can represent a pending request, but they cannot by themselves guarantee a remote side effect happened exactly once.
Common errors and fixes
The action runs during rendering
- Why it happens
- The handler prop contains a call result.
- How to fix it
- Pass the function or a callback instead.
- Prevent it next time
- Review event props for unintended invocation.
Three replacement updates add only one
- Why it happens
- Each uses the same render snapshot.
- How to fix it
- Use functional updates for repeated prior-state changes.
- Prevent it next time
- Trace the queued calculations explicitly.
A variable resets after another interaction
- Why it happens
- It is an ordinary local instead of state.
- How to fix it
- Use state for values that must persist and affect rendering.
- Prevent it next time
- Distinguish persistent state from derived local values.
Check your understanding
1. Predict two setCount(count + 1) calls when count is five.
Show worked solution
Both expressions evaluate to six in the same snapshot, so they request replacement with six rather than sequential increments to seven. Two functional updates c => c + 1 would instead compose to seven. The distinction is the queued calculation, not a timer race.
2. Where should a synthetic “save draft” action live when it happens only after clicking Save?
Show worked solution
Keep it in the Save event handler, with explicit pending and failure behavior. Do not create an Effect that sends a save every time a flag or field changes unless continuous synchronization is actually the requirement. This keeps action ownership and user intent visible.
Key takeaways
- Setters request another render.
- Handlers capture snapshots.
- Functional updates compose prior-state changes.