095: State + Events
Learning objective
Outcomes
You will attach event handlers, lift task state to a common parent, pass callback props to children, and calculate filtered views from one authoritative data source.
By the end, you should be able to build add and complete behavior and follow an event from a child component back to the state owned by its parent.
Prerequisites
Complete 094 first. You should already be comfortable with useState, immutable updater functions, controlled checkboxes, and values derived during rendering.
Retrieval practice
- Why does state stay unchanged inside a handler that is already running?
- When do you need an updater function instead of passing a value to a setter?
- Write an immutable toggle using
mapand object spread.
Content to cover
event handlers; lifting state; parent/child communication; derived state.
Terms and mental model
Rendering calculates the next UI. Events respond to one particular user action. In JSX, a handler is passed as a function; it is not called while React is rendering. When sibling components need to stay in sync, move the shared state to their closest common parent and pass the required values and callbacks down.
- Event handler: A function that responds to user interaction, attached through props such as
onClick. — Source: React: Responding to events - Callback prop: A function passed to a child so that the child can notify or request an action from its parent. — Source: React: Responding to events
- Lifting state: Moving shared state to the closest common parent of the components that use it. — Source: React: Sharing state between components
- Single source of truth: One authoritative copy of shared data from which views are derived. — Source: React: Sharing state between components
- Derived state: A value calculated during render from existing state rather than stored as another state variable. — Source: React: Managing state
- Event propagation: The process by which an event bubbles upward; React attaches its event handling at the root and simulates propagation through the React tree. — Source: React: Responding to events — propagation
Names such as onAddTask usually identify a callback prop, while names such as handleAddTask usually identify a local implementation. 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 the form and the list participate in that data. AddTask owns only its temporary text draft. When the form is submitted, it sends the cleaned title upward and then clears its draft. The parent creates the task's identity as part of handling the add event, rather than generating an ID during rendering. That keeps identity stable across renders.
Events are not Effects
Adding a task happens because the user submitted the form, so the work belongs in handleSubmit. Do not set a shouldAdd flag and then watch that flag from an Effect. The event already tells you what happened, and routing the action through an Effect only inserts an unnecessary state-and-render step. The same rule applies to deleting, buying, saving, or displaying a notification after a click: those actions belong in the handler that received the intent.
Pass a function:
<button onClick={handleDelete}>Delete</button>
<button onClick={() => handleDelete(task.id)}>Delete</button>
Do not call the function during render:
<button onClick={handleDelete(task.id)}>Delete</button>
React event objects expose target, currentTarget, preventDefault, and stopPropagation. Prevent the default form navigation when client-side code owns the submission. Use stopPropagation only when the interaction deliberately requires it, not as a substitute for understanding 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 also store visibleTasks, openCount, or allComplete; calculate each of them while rendering. An Effect that updates filtered state first renders stale data, then triggers a second render, and creates another synchronization path that can drift from the source data.
Lifting state tradeoff
Lift state only as far as the feature requires. If TaskItem alone needs a temporary hover detail, that detail does not need to live in App. If both a toolbar and a list need selectedTaskId, their common parent is the natural owner. Passing values and callbacks makes that ownership visible: the parent controls the committed data, while children present it and report user events.
Do not keep matching copies in the parent and child. A child can intentionally own a form draft, but the code should make clear when that draft is initialized and when it is committed. For shared, committed task data, choose one owner.
Optional advanced: event propagation and transitions
React handlers participate in event propagation. event.currentTarget is the element whose handler is currently executing; event.target is the deepest element that originated the event. Prefer separate, semantic controls instead of making an entire task row clickable when that row also contains nested buttons.
For a genuinely non-urgent, expensive view update, modern React provides transitions and deferred values. They are unnecessary for a small task list. Controlled input updates and immediate checkbox feedback should remain urgent. Do not add startTransition merely because it is available.
Mistakes and debugging
- Calling a handler during render causes immediate work or render loops.
- Mutating parent-owned data in a child breaks the ownership boundary.
- Duplicating visible tasks in state creates stale results.
- Moving submission into an Effect disconnects the work from the event that caused it.
- Forgetting
preventDefaultcauses a client-handled form to reload the page. - Generating IDs inside
mapdestroys stable identity. - Lifting every temporary detail to
Appcreates broad rerenders and unnecessary clutter. - Using clickable
divs removes the keyboard semantics users get from native controls.
When tracing a bug, follow the complete path: browser event → child handler → callback prop → parent handler → immutable setter → render → new props. Log the task ID at those boundaries rather than adding logs at arbitrary locations. React DevTools can show which component owns the state. If two controls disagree, look first for duplicated state.
Accessibility and performance
Use a form for adding tasks, labels for inputs, and a fieldset/legend pair for radio groups. Use buttons for actions. A row should not put nested interactive controls inside a clickable label except for the control associated with that label. Dynamic changes should preserve focus: after a toggle, focus normally remains on the checkbox; after deletion, a production interface should decide deliberately where focus moves.
Keep the input draft state in AddTask, which limits keystroke-driven renders to the component that needs them. Derive filtered arrays directly. Stable keys preserve focus and local component identity. Do not memoize callbacks until profiling identifies a meaningful issue; ordinary function props are idiomatic. React Compiler can also reduce the need for manual callback memoization.
Practice
Build a task list with add and complete behavior.
Tiered exercises
Core: Implement complete AddTask, TaskList, and parent ownership.
Stretch: Add delete plus all/open/complete filters, with visibleTasks derived during render.
Challenge: Add “complete all” and explain why neither an Effect nor duplicate count state is necessary.
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 the add operation, a click causes delete or complete-all, and rendering derives the current view and counts from tasks together with 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
Put interaction-specific work in event handlers. Lift shared state to the closest common parent, pass data and callbacks downward, and update parent state immutably. Keep one source of truth; derive filters and counts during rendering instead of trying to synchronize them through 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.
- Which values belong in state, and which should be derived?
- Why should a user-triggered POST remain in an event handler instead of an Effect?
Strong answer: The browser event invokes a child handler. The callback prop carries the child's request to 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
An event handler closes over the render in which it was created. That is why a delayed callback can observe an older value, and it is also why functional state updates are useful when a new value depends on the previous one. Keep feedback for urgent input interactions synchronous.
Use startTransition only for non-urgent updates that can safely be interrupted. Use useDeferredValue when a derived view may lag behind an input without making the input itself feel slow. Measure before optimizing, and do not use transitions to conceal a state update that is modeled incorrectly.
Context, portals, and propagation
Every consumer that reads a changed context provider value is eligible to rerender. A provider that creates { user, signOut: () => ... } on every render changes the value's identity even when user has not changed. Split providers according to their change rates and keep fast-changing state local where possible. memo does not protect a context consumer from a changed context value.
Portals change where elements are placed in the DOM, not which React components own them. An event from a portal bubbles 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 stopPropagation call is intentional here: closing or interacting with the modal must not activate the card behind it. Test both behaviors: clicking Close should call onClose, and clicking the dialog surface should not call the card handler. Useful interview follow-ups are whether a portal breaks context, which tree controls event bubbling, and why a context consumer can rerender despite memo.
2026 depth expansion: events are where user intent becomes state transitions
React event handlers run because something happened: a click, input, submit, keyboard interaction, pointer interaction, or another event.
Keep the event logic close to the intent it represents:
function DeleteButton({ taskId, onDelete }) {
return (
<button type="button" onClick={() => onDelete(taskId)}>
Delete
</button>
);
}
Do not turn 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, start the delete workflow from the click handler. The direct path preserves the cause of the action and avoids using a render as an indirect event queue.
Propagation matters
Events bubble through the React tree. Use stopPropagation() only when nested interactions genuinely must not trigger the parent behavior. A clickable card containing real buttons and links often indicates that the interaction model needs redesign, rather than a growing collection of propagation calls.
Deep dive: event handling is where domain intent should become state transitions
React events are more than syntax wrapped around browser events. They are the boundary where a user's intent enters the 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>
);
}
Here the child emits domain intent, not the parent's implementation details. The parent decides how that intent changes state, persists data, or dispatches an action.
Event object lifetime and values
React's modern event system no longer requires event.persist() for ordinary asynchronous use. Even so, avoid passing a DOM event deep into domain code when the receiving code needs only one value.
Instead of this:
function SearchBox({ onChange }) {
return <input onChange={onChange} />;
}
and making the parent understand the DOM event shape:
function handleChange(event) {
setQuery(event.target.value);
}
a reusable domain component can expose the value it has already interpreted:
function SearchBox({ value, onValueChange }) {
return (
<input
value={value}
onChange={(event) => onValueChange(event.target.value)}
/>
);
}
Both designs can be valid. The deciding question is whether callers should depend on the DOM event contract or on a smaller, domain-level value contract.
Event propagation in real interfaces
Suppose the whole card opens its details view:
<article onClick={() => openTask(task.id)}>
<h2>{task.title}</h2>
<button onClick={deleteTask}>Delete</button>
</article>
Because the event bubbles, clicking Delete can also invoke the card's click handler.
A quick patch is:
function handleDelete(event) {
event.stopPropagation();
onDelete(task.id);
}
That may be appropriate, but inspect the interaction semantics before adding it.
A clickable <article> is not keyboard-interactive by default. A clearer design is often:
<article>
<h2>
<Link to={`/tasks/${task.id}`}>{task.title}</Link>
</h2>
<button type="button" onClick={handleDelete}>
Delete
</button>
</article>
Navigation is now represented by a link and deletion by a button. The propagation problem largely disappears because the controls describe their actual semantics.
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 preventDefault when your code intentionally replaces a browser default action.
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. It should communicate a deliberate decision about the browser behavior being replaced.
With modern React Actions or React Router's <Form>, the framework owns the submission behavior, so a manual submit handler is often unnecessary.
Keyboard events
Do not recreate native button behavior with a div:
<div
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
activate();
}
}}
onClick={activate}
>
Save
</div>
when this is all that is needed:
<button type="button" onClick={activate}>
Save
</button>
Native controls provide keyboard behavior, focus handling, disabled semantics, form integration, and accessibility behavior without requiring you to reproduce every detail.
Use keyboard events for genuinely 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 the feature genuinely needs one model for different pointer types:
function ResizeHandle() {
function handlePointerDown(event) {
event.currentTarget.setPointerCapture(event.pointerId);
}
return (
<div
role="separator"
tabIndex={0}
onPointerDown={handlePointerDown}
/>
);
}
Complex pointer interactions need accessible alternatives as well. A drag-only interface with no keyboard control can block users from completing the same task.
Event handler identity
This is ordinary React code:
<button onClick={() => onDelete(task.id)}>
Delete
</button>
A new function is created during each render, but that fact alone does not make the code a performance problem.
Optimize callback identity only when:
- profiling shows a meaningful issue;
- a memoized child depends on stable identity;
- a library API explicitly uses identity;
- an Effect dependency genuinely requires it.
React Compiler can also reduce the need for manual callback memoization.
Handler versus Effect
If the logic runs 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 synchronizes with an external system when rendering or state changes require that synchronization. It should not act as an indirect event queue.
Event batching and snapshots
function handleClick() {
setCount(count + 1);
setOpen(true);
console.log(count);
}
Inside this handler, count still refers to the current render's snapshot. Calling the setter schedules an update; it does not rewrite the value captured by the already-running handler.
When an update depends on the previous state, use an updater function:
setCount((current) => current + 1);
If several state values together describe one domain transition, consider a reducer instead of maintaining a collection of unrelated setters.
Async event handlers
async function handleSave() {
setSaving(true);
setError(null);
try {
await saveTask(draft);
} catch (error) {
setError(error);
} finally {
setSaving(false);
}
}
This pattern is useful while learning the lifecycle. In a larger application, React Actions or TanStack Query mutations may own much of the pending, error, and completion state.
There is also a race to design for: if users can click Save repeatedly, decide whether to:
- disable duplicate submission;
- queue submissions;
- cancel previous work;
- use idempotency on the server.
Disabling the client control improves UX, but server-side idempotency and validation are what protect trust and handle duplicate requests.
Failure clinic
Calling handler during render
Wrong:
<button onClick={saveTask()}>
This invokes the function immediately while React is rendering.
Correct:
<button onClick={saveTask}>
or:
<button onClick={() => saveTask(task.id)}>
Storing event in state
Usually not useful:
setLastEvent(event);
Store the meaningful application data instead:
setSelectedId(task.id);
Button without type inside form
<button onClick={openHelp}>Help</button>
Inside an HTML form, that button defaults to submit.
Use:
<button type="button" onClick={openHelp}>
Help
</button>
unless the button is intended to submit the form.
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 or assistive-technology shortcuts?
- Are shortcuts discoverable?
- Are the actions still available without shortcuts?
Exercises
- Refactor a clickable card into a semantic link with button actions.
- Demonstrate bubbling with nested handlers, then remove any 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 how duplicate submissions behave.
- 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 recreating a handler is not automatically a performance problem.
