Skip to content

React State, Events, and Effects

A React component's render is a snapshot. Props and state values belong to that particular render, and the returned JSX describes what the interface should look like for them.

jsx
function Counter() {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Calling setCount requests another render. It does not mutate the count constant already captured by the current event handler.

State updates are queued

jsx
function incrementThreeTimes() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Each call uses the same snapshot, so this requests count + 1, not three increments. When the next value depends on the previous queued value, use an updater:

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

Objects and arrays in state should be replaced rather than mutated:

jsx
setTransactions(current =>
  current.map(transaction =>
    transaction.id === id
      ? { ...transaction, settled: true }
      : transaction,
  ),
);

React uses identity to detect changes and may retain old snapshots. Mutation makes those snapshots inconsistent and can prevent rendering.

Store the minimum state

If a value can be calculated from current props and state during rendering, do not synchronize another state variable for it:

jsx
const visibleTransactions = transactions.filter(transaction =>
  transaction.description.toLowerCase().includes(query.toLowerCase()),
);

Storing both transactions and visibleTransactions creates two sources of truth. Use memoization only when calculation is observably expensive or stable identity is required; useMemo is a performance tool, not a semantic requirement.

State belongs at the nearest common owner of components that need to read or change it. Pass values down and event callbacks up:

jsx
function TransactionRow({ transaction, onSettle }) {
  return (
    <button onClick={() => onSettle(transaction.id)}>
      Settle {transaction.description}
    </button>
  );
}

This “lifting state up” keeps ownership explicit. Context helps with widely shared values such as theme or session, but placing all changing application state in one context can cause broad coupling and rendering.

Events are where user-driven side effects begin

Pass a function as a handler; do not call it while rendering:

jsx
<button onClick={save}>Save</button>
<button onClick={() => save(transaction.id)}>Save</button>

onClick={save()} runs immediately and passes its result as the handler.

Event handlers capture the render in which they were created. This is normally useful. Async handlers should account for state changing while they await:

jsx
async function handleSubmit(event) {
  event.preventDefault();
  const submittedDraft = draft;
  setStatus("saving");

  try {
    await saveDraft(submittedDraft);
    setStatus("saved");
  } catch (error) {
    setStatus("failed");
  }
}

The local value intentionally records what was submitted.

Effects synchronize with external systems

An effect is appropriate when rendering must be synchronized with something React does not own: a subscription, browser API, network connection, media element, or third-party widget.

jsx
useEffect(() => {
  document.title = `${transactions.length} transactions`;
}, [transactions.length]);

Dependencies are not an optimization list. They describe reactive values read by the effect. The linter can identify missing dependencies because a stale closure otherwise continues to see values from an older render.

Subscriptions return cleanup:

jsx
useEffect(() => {
  function handleOnline() {
    setOnline(true);
  }

  window.addEventListener("online", handleOnline);
  return () => window.removeEventListener("online", handleOnline);
}, []);

React runs cleanup before re-synchronizing and when the component unmounts. Development Strict Mode may mount, clean up, and mount again to expose effects that are not safely repeatable.

Do not use an effect for logic caused by a specific user action; keep that in the handler. Do not use one merely to derive state during rendering. Many effects disappear once ownership is modeled correctly.

Fetching in an effect needs stale-result handling:

jsx
useEffect(() => {
  const controller = new AbortController();

  async function load() {
    setState({ status: "loading" });
    try {
      const value = await loadTransactions({ signal: controller.signal });
      setState({ status: "success", value });
    } catch (error) {
      if (error.name !== "AbortError") {
        setState({ status: "failure", error });
      }
    }
  }

  void load();
  return () => controller.abort();
}, [accountId]);

Framework data loaders and query libraries often handle caching, deduplication, server rendering, and race policies better than hand-written effects. Effects remain important for understanding what those tools coordinate.

Refs retain values without causing renders

jsx
const inputRef = useRef(null);

function focusSearch() {
  inputRef.current?.focus();
}

return <input ref={inputRef} />;

Refs are appropriate for DOM nodes and mutable bookkeeping that does not affect visual output. If changing a value should update the UI, it belongs in state.

Hooks must be called at the top level of React components or custom hooks, not conditionally or inside loops. React associates hook state with call order. A custom hook packages stateful behavior and effects; it does not create shared state unless it connects to a shared external owner.

React becomes much easier to reason about when render stays a pure calculation, events represent user intent, state has a clear owner, and effects are reserved for synchronization beyond React.

Further reference