095: State + Events
Learning objective
Outcomes
You will attach event handlers, lift task state to a common parent, send callback props to children, and derive filtered data from one source of truth.
I can build add/complete behavior and trace an event from a child back to parent-owned state.
Prerequisites
Complete 094. You should be able to use useState, immutable updater functions, controlled checkboxes, and derived values.
Retrieval practice
- Why does state remain unchanged inside an already-running handler?
- When is an updater function required?
- Write an immutable toggle with
mapand object spread.
Content to cover
event handlers; lifting state; parent/child communication; derived state.
Terms and mental model
Rendering calculates. Events respond to a particular user action. A handler is passed, not called, in JSX. When siblings need synchronized data, move that state to their closest common parent and pass values and callbacks downward.
- Event handler: Function responding to user interactions, attached via props like onClick. — Source: React: Responding to events
- Callback prop: Function passed down so a child can notify its parent. — Source: React: Responding to events
- Lifting state: Moving shared state to the closest common parent of siblings. — Source: React: Sharing state between components
- Single source of truth: One authoritative copy of shared data; views derive from it. — Source: React: Sharing state between components
- Derived state: Values computed during render from existing state instead of stored separately. — Source: React: Managing state
- Event propagation: Events bubbling upward; React attaches at the root and simulates propagation per tree. — Source: React: Responding to events — propagation
Names such as onAddTask describe props; names such as handleAddTask describe local implementations. Built-in elements use browser event names such as onClick and onSubmit.
Beginner complete example
import { useState } from 'react';
function AddTask({ onAddTask }) {
const [title, setTitle] = useState('');
function handleSubmit(event) {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
onAddTask(trimmedTitle);
setTitle('');
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="new-task">New task</label>
<input
id="new-task"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<button type="submit">Add task</button>
</form>
);
}
function TaskList({ tasks, onToggleTask }) {
if (tasks.length === 0) return <p>No tasks yet.</p>;
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggleTask(task.id)}
/>
{task.title}
</label>
</li>
))}
</ul>
);
}
export default function App() {
const [tasks, setTasks] = useState([]);
function handleAddTask(title) {
setTasks((current) => [
...current,
{ id: crypto.randomUUID(), title, completed: false },
]);
}
function handleToggleTask(taskId) {
setTasks((current) => current.map((task) =>
task.id === taskId ? { ...task, completed: !task.completed } : task,
));
}
const openCount = tasks.filter((task) => !task.completed).length;
return (
<main>
<h1>Task Manager</h1>
<AddTask onAddTask={handleAddTask} />
<p>{openCount} open</p>
<TaskList tasks={tasks} onToggleTask={handleToggleTask} />
</main>
);
}
App owns tasks because both form and list participate. AddTask owns only its temporary text draft. On submit, it sends a title upward, then resets its draft. The parent assigns identity during the add event, not during rendering.
Events are not Effects
Adding a task is caused by submit, so perform it in handleSubmit. Do not set “shouldAdd” state and watch it from an Effect. The event already tells you what happened. Likewise, deleting, buying, saving, and showing a click notification belong in handlers.
Pass a function:
<button onClick={handleDelete}>Delete</button>
<button onClick={() => handleDelete(task.id)}>Delete</button>
Do not call during render:
<button onClick={handleDelete(task.id)}>Delete</button>
React event objects expose target, currentTarget, preventDefault, and stopPropagation. Prevent default form navigation when client code handles submit. Use stopPropagation only for a deliberate interaction design, not to patch unclear nested click targets. Never nest buttons.
Intermediate: filter and derived values
function TaskFilters({ value, onChange }) {
return (
<fieldset>
<legend>Show tasks</legend>
{['all', 'open', 'complete'].map((filter) => (
<label key={filter}>
<input
type="radio"
name="task-filter"
value={filter}
checked={value === filter}
onChange={(event) => onChange(event.target.value)}
/>
{filter}
</label>
))}
</fieldset>
);
}
In App:
const [filter, setFilter] = useState('all');
const visibleTasks = tasks.filter((task) => {
if (filter === 'open') return !task.completed;
if (filter === 'complete') return task.completed;
return true;
});
Store tasks and filter. Do not store visibleTasks, openCount, or allComplete; calculate them during render. An Effect that updates filtered state would render stale data first, then cause a second render and introduce synchronization paths.
Lifting state tradeoff
Lift only as high as required. If TaskItem alone controls a temporary hover detail, it need not live in App. If toolbar and list both need selectedTaskId, their common parent should own it. Passing values and callbacks makes control explicit: the parent controls, children present and report events.
Do not maintain matching copies in parent and child. A child may own a form draft intentionally, but define when it is initialized and committed. For shared committed task data, use one owner.
Optional advanced: event propagation and transitions
React handlers participate in event propagation. event.currentTarget is the element whose handler is running; event.target is the deepest origin. Prefer separate controls over making a whole task row clickable with nested buttons.
For genuinely non-urgent expensive view updates, modern React offers transitions and deferred values. They are unnecessary for a small task list. Controlled inputs and immediate checkbox feedback should remain urgent. Do not add startTransition as decoration.
Mistakes and debugging
- Calling handlers during render causes loops or immediate actions.
- Mutating parent data in a child violates ownership.
- Duplicating visible tasks in state creates stale results.
- Placing submission in an Effect disconnects work from its cause.
- Forgetting
preventDefaultreloads a client-handled form. - Generating IDs in
mapdestroys stable identity. - Lifting every temporary detail to
Appcauses broad rerenders and clutter. - Using clickable
divs loses keyboard semantics.
Trace a bug as: browser event → child handler → callback prop → parent handler → immutable setter → render → new props. Log IDs at those boundaries, not random points. React DevTools shows which component owns state. If two controls disagree, search for duplicated state.
Accessibility and performance
Use a form for adding, labeled inputs, fieldset/legend for radio groups, and buttons for actions. Ensure a row does not contain nested interactive controls inside a clickable label except its associated checkbox. Dynamic changes should preserve focus: after toggling, focus stays on the checkbox; after deleting, consider where focus should move in a production UI.
Keep input draft state in AddTask, limiting keystroke renders. Derive normal filtered arrays directly. Stable keys preserve focus and local identity. Avoid callback memoization until profiling reveals a meaningful issue; ordinary function props are idiomatic.
Practice
Build a task list with add/complete behavior.
Tiered exercises
Core: Implement complete AddTask, TaskList, and parent ownership.
Stretch: Add delete and all/open/complete filters with derived visibleTasks.
Challenge: Add “complete all” and explain why no Effect or duplicate count state is needed.
Add these parent handlers and values to the beginner solution:
const [filter, setFilter] = useState('all');
const visibleTasks = tasks.filter((task) =>
filter === 'open' ? !task.completed : filter === 'complete' ? task.completed : true,
);
function handleDeleteTask(id) {
setTasks((current) => current.filter((task) => task.id !== id));
}
function handleCompleteAll() {
setTasks((current) => current.map((task) => ({ ...task, completed: true })));
}
Render <TaskFilters value={filter} onChange={setFilter} />, pass visibleTasks to the list, and add this button inside each li:
<button type="button" onClick={() => onDeleteTask(task.id)}>
Delete {task.title}
</button>
No Effect is needed: submit causes add, click causes delete/complete-all, and render derives the current view and counts from tasks plus filter.
Exit questions
- What problem does this concept solve?
- What is one common mistake?
- Can you explain the code without reading it line by line?
Recap
Event handlers contain interaction-specific work. Lift shared state to the closest common parent, send data and callbacks down, and update parent state immutably. Keep one source of truth and derive filters and counts during rendering rather than synchronizing them with Effects.
Official references
- React: Responding to Events
- React: Sharing State Between Components
- React: Choosing the State Structure
- React: You Might Not Need an Effect
Interview questions
- Trace a checkbox event from the DOM to the next rendered task.
- What belongs in state versus a derived variable?
- Why should a user-triggered POST stay in an event handler rather than an Effect?
Strong answer: The browser event calls a child handler, the callback prop requests a parent-owned immutable update, and the next render derives the visible view. Effects synchronize external systems; they do not replay user intent.
Events, closures, and transitions in React
Event handlers close over the render in which they were created. This explains stale values in delayed callbacks and why functional state updates are useful. Keep urgent input feedback synchronous.
Use startTransition only for non-urgent updates whose interruption is acceptable, and use useDeferredValue when a derived view can lag behind an input without making the input itself lag. Measure before optimizing and do not use transitions to hide an incorrectly modeled state update.
Context, portals, and propagation
Every consumer that reads a changed context provider value is eligible to rerender. A provider that creates { user, signOut: () => ... } during every render changes identity even when the user did not change. Split providers by change rate and keep fast-changing state local. memo does not shield a consumer from changed context.
Portals change DOM placement, not React ownership. Events from a portal bubble through the React parent tree:
import { createContext, useState } from 'react';
import { createPortal } from 'react-dom';
const SessionContext = createContext(null);
function Dialog({ onClose }) {
return createPortal(<div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}><button onClick={onClose}>Close</button></div>, document.body);
}
function Card() { return <div onClick={() => console.log('card')}><Dialog onClose={() => {}} /></div>; }
The stop is intentional because the modal must not activate the card. Test that Close calls onClose and that clicking the dialog surface does not call the card handler. Interview follow-ups: does a portal break context, which tree controls event bubbling, and why can a context consumer rerender despite memo?
2026 depth expansion: events are where user intent becomes state transitions
React event handlers run because something happened: click, input, submit, keyboard interaction, pointer interaction, or another event.
Keep event logic close to the intent:
function DeleteButton({ taskId, onDelete }) {
return (
<button type="button" onClick={() => onDelete(taskId)}>
Delete
</button>
);
}
Do not convert an event into an Effect:
// Avoid: state is being used as an indirect event signal
const [shouldDelete, setShouldDelete] = useState(false);
useEffect(() => {
if (shouldDelete) deleteTask(taskId);
}, [shouldDelete, taskId]);
If the user clicked Delete, call the delete workflow from the click handler.
Propagation matters
Events bubble through the React tree. Use stopPropagation() only when nested interactions truly should not trigger the parent behavior. A clickable card containing real buttons and links often signals that the interaction model should be redesigned instead of patched with propagation calls.
Deep dive: event handling is where domain intent should become state transitions
React events are not just syntax around browser events. They are the place where user intent enters your application.
A useful separation is:
event → interpret user intent → validate immediate client rules → update local state / dispatch / navigate / start mutation → render the next UI
For example:
function TaskRow({ task, onToggle }) {
function handleToggle() {
onToggle({
id: task.id,
completed: !task.completed,
});
}
return (
<button type="button" onClick={handleToggle}>
{task.completed ? 'Reopen' : 'Complete'}
</button>
);
}
The child emits domain intent rather than exposing parent implementation details.
Event object lifetime and values
React's modern event system no longer requires calling event.persist() for normal async use, but you should still avoid passing DOM events deep into domain code when only a value is needed.
Instead of:
function SearchBox({ onChange }) {
return <input onChange={onChange} />;
}
and then making the parent know DOM shape:
function handleChange(event) {
setQuery(event.target.value);
}
a reusable domain component may expose:
function SearchBox({ value, onValueChange }) {
return (
<input
value={value}
onChange={(event) => onValueChange(event.target.value)}
/>
);
}
Both patterns can be valid. The question is whether the caller should depend on the DOM event contract.
Event propagation in real interfaces
Suppose the whole card opens details:
<article onClick={() => openTask(task.id)}>
<h2>{task.title}</h2>
<button onClick={deleteTask}>Delete</button>
</article>
Clicking Delete can also trigger the card click because the event bubbles.
A quick patch:
function handleDelete(event) {
event.stopPropagation();
onDelete(task.id);
}
may be appropriate, but first examine semantics.
A clickable <article> is not keyboard-interactive by default. A better design might use:
<article>
<h2>
<Link to={`/tasks/${task.id}`}>{task.title}</Link>
</h2>
<button type="button" onClick={handleDelete}>
Delete
</button>
</article>
Now navigation is a link and deletion is a button. The event problem largely disappears because the semantics are clearer.
Use stopPropagation intentionally
Appropriate:
- nested drag handles;
- composite widgets with documented event behavior;
- overlay interactions.
Suspicious:
- every button inside a clickable div;
- many handlers canceling one another;
- propagation used to compensate for invalid semantics.
preventDefault
Use it when you intentionally replace a browser default.
Classic controlled submit:
function TaskForm() {
function handleSubmit(event) {
event.preventDefault();
// submit through JavaScript
}
return <form onSubmit={handleSubmit}>...</form>;
}
Do not call preventDefault on every event by habit.
For modern React Actions or React Router <Form>, the framework owns the submission behavior; you often do not need a manual submit handler at all.
Keyboard events
Do not recreate native button behavior:
<div
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
activate();
}
}}
onClick={activate}
>
Save
</div>
when this is sufficient:
<button type="button" onClick={activate}>
Save
</button>
Native controls provide keyboard, focus, disabled semantics, form integration, and accessibility behavior.
Use keyboard events for actual keyboard-specific product interactions, such as:
- Escape to close a custom overlay;
- Arrow keys in a composite widget;
- keyboard shortcuts.
Pointer, mouse, and touch events
Prefer pointer events when you genuinely need unified pointer handling:
function ResizeHandle() {
function handlePointerDown(event) {
event.currentTarget.setPointerCapture(event.pointerId);
}
return (
<div
role="separator"
tabIndex={0}
onPointerDown={handlePointerDown}
/>
);
}
Complex pointer interactions need additional accessibility alternatives.
A drag-only interface without keyboard controls can block users.
Event handler identity
This is normal:
<button onClick={() => onDelete(task.id)}>
Delete
</button>
A new function is created during render. That is not automatically a performance problem.
Only optimize callback identity when:
- profiling shows a meaningful issue;
- a memoized child depends on stable identity;
- a library API explicitly uses identity;
- an Effect dependency truly requires it.
React Compiler can also reduce the need for manual callback memoization.
Handler versus Effect
If logic happens because the user clicked a button, keep it in the event path.
Bad:
const [requestedExport, setRequestedExport] = useState(false);
useEffect(() => {
if (requestedExport) {
exportReport();
}
}, [requestedExport]);
Better:
async function handleExport() {
await exportReport();
}
An Effect should synchronize with external systems because rendering/state requires synchronization, not because you needed an indirect event queue.
Event batching and snapshots
function handleClick() {
setCount(count + 1);
setOpen(true);
console.log(count);
}
count still reflects the current render snapshot inside the handler.
If one update depends on previous state:
setCount((current) => current + 1);
If several pieces of state form one domain transition, consider a reducer rather than many unrelated setters.
Async event handlers
async function handleSave() {
setSaving(true);
setError(null);
try {
await saveTask(draft);
} catch (error) {
setError(error);
} finally {
setSaving(false);
}
}
This is useful for learning. Later, React Actions or TanStack Query mutations will own much of this lifecycle.
Important race issue:
If users can click Save repeatedly, decide whether to:
- disable duplicate submission;
- queue submissions;
- cancel previous work;
- use idempotency on the server.
Client disabling is UX; server idempotency/validation handles trust and duplicate requests.
Failure clinic
Calling handler during render
Wrong:
<button onClick={saveTask()}>
This calls it immediately.
Correct:
<button onClick={saveTask}>
or:
<button onClick={() => saveTask(task.id)}>
Storing event in state
Rarely useful:
setLastEvent(event);
Store meaningful data instead:
setSelectedId(task.id);
Button without type inside form
<button onClick={openHelp}>Help</button>
defaults to submit in HTML forms.
Use:
<button type="button" onClick={openHelp}>
Help
</button>
unless the button should submit.
Worked exercise: accessible command bar
function TaskCommandBar({ onAdd, onRefresh }) {
function handleKeyDown(event) {
if (event.ctrlKey && event.key.toLowerCase() === 'n') {
event.preventDefault();
onAdd();
}
if (event.ctrlKey && event.key.toLowerCase() === 'r') {
event.preventDefault();
onRefresh();
}
}
return (
<section onKeyDown={handleKeyDown}>
<button type="button" onClick={onAdd}>
New task
</button>
<button type="button" onClick={onRefresh}>
Refresh
</button>
</section>
);
}
Then ask:
- Should shortcuts be global or scoped?
- Do they conflict with browser/assistive technology shortcuts?
- Are shortcuts discoverable?
- Are actions still available without shortcuts?
Exercises
- Refactor a clickable card into semantic link + button actions.
- Demonstrate bubbling with nested handlers, then remove unnecessary
stopPropagation. - Create a form with a non-submit Help button and verify its
type. - Move event-specific work out of an Effect.
- Implement an async save workflow and define duplicate-submit behavior.
- Audit keyboard interactions for one custom widget.
Mastery check
Explain:
- event bubbling;
- default browser behavior;
- when to expose an event versus a domain value;
- why event logic and Effect synchronization are different;
- why native elements reduce interaction bugs;
- why handler recreation is not automatically a performance problem.
