Module: React and Ecosystem
React and Ecosystem·095·10 MIN READ

095: State + Events

TOPICS COVERED: 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

  1. Why does state remain unchanged inside an already-running handler?
  2. When is an updater function required?
  3. Write an immutable toggle with map and 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.

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

jsx
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:

jsx
<button onClick={handleDelete}>Delete</button>
<button onClick={() => handleDelete(task.id)}>Delete</button>

Do not call during render:

jsx
<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

jsx
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:

jsx
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 preventDefault reloads a client-handled form.
  • Generating IDs in map destroys stable identity.
  • Lifting every temporary detail to App causes 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:

jsx
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:

jsx
<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

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. 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

Interview questions

  1. Trace a checkbox event from the DOM to the next rendered task.
  2. What belongs in state versus a derived variable?
  3. 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:

jsx
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:

jsx
function DeleteButton({ taskId, onDelete }) {
  return (
    <button type="button" onClick={() => onDelete(taskId)}>
      Delete
    </button>
  );
}

Do not convert an event into an Effect:

jsx
// 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:

text
event
→ interpret user intent
→ validate immediate client rules
→ update local state / dispatch / navigate / start mutation
→ render the next UI

For example:

jsx
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:

jsx
function SearchBox({ onChange }) {
  return <input onChange={onChange} />;
}

and then making the parent know DOM shape:

jsx
function handleChange(event) {
  setQuery(event.target.value);
}

a reusable domain component may expose:

jsx
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:

jsx
<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:

jsx
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:

jsx
<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:

jsx
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:

jsx
<div
  role="button"
  tabIndex={0}
  onKeyDown={(event) => {
    if (event.key === 'Enter' || event.key === ' ') {
      activate();
    }
  }}
  onClick={activate}
>
  Save
</div>

when this is sufficient:

jsx
<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:

jsx
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:

jsx
<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:

jsx
const [requestedExport, setRequestedExport] = useState(false);

useEffect(() => {
  if (requestedExport) {
    exportReport();
  }
}, [requestedExport]);

Better:

jsx
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

jsx
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:

jsx
setCount((current) => current + 1);

If several pieces of state form one domain transition, consider a reducer rather than many unrelated setters.

Async event handlers

jsx
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:

jsx
<button onClick={saveTask()}>

This calls it immediately.

Correct:

jsx
<button onClick={saveTask}>

or:

jsx
<button onClick={() => saveTask(task.id)}>

Storing event in state

Rarely useful:

jsx
setLastEvent(event);

Store meaningful data instead:

jsx
setSelectedId(task.id);

Button without type inside form

jsx
<button onClick={openHelp}>Help</button>

defaults to submit in HTML forms.

Use:

jsx
<button type="button" onClick={openHelp}>
  Help
</button>

unless the button should submit.

Worked exercise: accessible command bar

jsx
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

  1. Refactor a clickable card into semantic link + button actions.
  2. Demonstrate bubbling with nested handlers, then remove unnecessary stopPropagation.
  3. Create a form with a non-submit Help button and verify its type.
  4. Move event-specific work out of an Effect.
  5. Implement an async save workflow and define duplicate-submit behavior.
  6. 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.