Trust is earned, not given

A different perspective

2026-02-24 · Projects

React.js, part 17: startTransition with async functions and the useEffectEvent escape hatch

Part 17from the React.js series · 20 parts in all

Two ergonomic gaps closed by the mid-2020s: transitions that wrap asynchronous work, and a principled way for an effect to read the latest value without re-subscribing. Both look small and both remove a category of stale-closure bugs.

Async transitions

A transition used to accept only synchronous work — an await inside it, and React had already left the transition by the time the promise settled. React now keeps the transition open across awaits, so pending state and the "keep showing the old UI" behavior cover the whole operation:

const [isPending, startTransition] = useTransition();

function selectTab(tabId) {
  // The whole async operation stays inside one transition: a spinner for the
  // duration, and the old tab's content kept on screen while the data loads.
  startTransition(async () => {
    const data = await loadTab(tabId);
    setTab(tabId);
    setData(data);
  });
}

The practical effect is that in-place navigation stops flashing a fallback: the previous screen stays painted, dimmed by your own isPending styling, until the new one is ready.

useEffectEvent: the latest value without a dependency

The perennial effect problem: a long-lived subscription needs to report analytics with the current user id, but adding it to the dependency array re-subscribes on every change. The old workarounds were a ref or turning the whole thing off in a linter. The proper tool makes the intent explicit:

'use client';

import { useEffect, useEffectEvent } from 'react';

function Chat({ roomId, userId }) {
  // Not a dependency: the event always sees the latest props, but changing
  // them does not re-run the effect.
  const onMessage = useEffectEvent((msg) => {
    log('message', { roomId, userId, len: msg.length });
  });

  useEffect(() => {
    const socket = openSocket(roomId);     // reconnects only when roomId changes
    socket.on('message', onMessage);
    return () => socket.close();
  }, [roomId]);                            // userId deliberately absent

  return <Room roomId={roomId} />;
}

The rule that keeps it honest

An effect event is for code that responds to an event — a socket message, a timer tick, a resize. It must not be called during render, and it must not be used to smuggle a changing value into a computation that should genuinely re-run. If the effect's work depends on the value at the moment it starts, that value belongs in the dependency array, not in an effect event. Next: testing this whole stack without writing brittle tests.