Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Optimistic UI updates with useOptimistic

Turtlekit Editorial Team

Three seconds is a long time to watch a button read “Sending…” while nothing else moves. On the team page of a billing app, that is how long an invite took over a train’s connection, and the list of members stayed as it was until the server answered.

Admins did what people do with a screen that seems frozen. Some clicked again, and their teammate got two emails. Others left the page, sure it had failed, while the invite went out anyway.

Optimistic UI removes the wait. The screen shows the expected result at once, while the request is in flight, and settles to the server’s answer when it arrives. React 19 builds this in with useOptimistic, and a refused request rolls back with no undo code.

The form that waited for permission

The first version did the careful thing. It sent the invite, waited, and only then touched the list:

// Avoid: the list waits for the round trip
async function submit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const email = String(
    new FormData(event.currentTarget).get("email"),
  );
  setSending(true);
  try {
    const member = await sendInvite({
      id: crypto.randomUUID(),
      email,
    });
    setMembers((current) => [...current, member]);
  } finally {
    setSending(false);
  }
}

Nothing in it is wrong. It is pessimistic, and on a slow connection pessimism looks like a broken page. Showing a result before the server confirms it can sound like misleading people. It isn’t, as long as the row says it is pending and a refusal takes it back with an explanation.

A prediction drawn on top of the truth

Think of two layers. The confirmed state is what the server has accepted. The optimistic layer sits on top of it only while an Action runs, an Action being a function, sync or async, that React runs inside a transition. useOptimistic is that layer.

clickserver answersConfirmed2 members3 membersOn screen3 · Sending…3 · InvitedIf it fails3 · Sending…2 + message
The prediction is drawn on top of the confirmed state only while the Action runs. When it ends, the confirmed state is what shows: with the new member, or without it and with a message.

There is no undo step. When the Action ends the layer goes, and whatever the truth is by then is what people see.

Building the invite list

The hook comes first, in two fragments of team-invites.ts, then the page that uses it, and then a live version to try.

Two layers in one hook

The confirmed members live in ordinary state. The prediction is a reducer over them, applied only while an Action is pending:

export function useTeamInvites(
  initial: Member[],
  sendInvite: SendInvite,
) {
  const [members, setMembers] = useState(initial);
  const [failed, setFailed] = useState<Invite | null>(null);
  // Replace by id, so a retried invite never shows twice.
  const [shown, addPending] = useOptimistic(
    members,
    (current: Member[], pending: Invite): Member[] => [
      ...current.filter((member) => member.id !== pending.id),
      { ...pending, status: "sending" },
    ],
  );

The reducer always receives the latest members, so a change that lands in the meantime, such as a teammate accepting an earlier invite, stays visible under the prediction.

Predict, send, commit

The Action shows the prediction, sends the request and commits the server’s answer. Look at where the id comes from:

// The Action: predict, send, then commit the answer in the same
// transition.
async function inviteAction(invite: Invite) {
  addPending(invite);
  try {
    const member = await sendInvite(invite);
    // After an await, an update needs its own transition.
    startTransition(() =>
      setMembers((current) => [
        ...current.filter((m) => m.id !== member.id),
        member,
      ]),
    );
  } catch {
    // In the transition too: the message and the rollback show
    // together.
    startTransition(() => setFailed(invite));
  }
}

// Clears the last failure at once, then starts the Action.
function send(invite: Invite) {
  setFailed(null);
  startTransition(() => inviteAction(invite));
}

The id is created in the browser before the request leaves. It keys the row, so the pending row and the confirmed one are the same element, and it doubles as the request’s idempotency key, which makes a retry safe. Here is Sam’s invite being refused:

One refused invite, step by step
MomentmembersOn screen
Before the clickPriya, TomásPriya, Tomás
While the request is outPriya, TomásPriya, Tomás, Sam (sending)
After the refusalPriya, TomásPriya, Tomás, and “We couldn’t invite Sam”

The rollback is the absence of an update. members never changed, so once the Action ends React renders it as it was, which the useOptimistic reference describes in detail. The catch records the refused invite, inside the transition too, so the message and the rollback appear together.

Pitfall

If Sam ever shows up twice, look at the update after the await. React loses the async context there, so that update is not part of the transition. It renders at once, with the optimistic layer still on top, and Sam appears as invited and as sending until the Action ends. Wrap it in startTransition, as the useTransition reference shows.

Starting the Action from the form

The hook’s send clears the last failure at once, outside any transition, then starts the Action in one; the arrow returns the Action’s promise, which keeps the prediction alive until the request settles. The page clears the field as it submits, so the next address can be typed while the first invite is out:

// The team page: invite a teammate, see them in the list at once.
import type { FormEvent } from "react";
import { useTeamInvites } from "./team-invites";
import type { Member, SendInvite } from "./team-invites";

const STATUS = {
  active: "Active",
  invited: "Invited",
  sending: "Sending…",
};

interface TeamMembersProps {
  initial: Member[];
  sendInvite: SendInvite;
}

export function TeamMembers({
  initial,
  sendInvite,
}: TeamMembersProps) {
  const { members, failed, send } = useTeamInvites(
    initial,
    sendInvite,
  );
  const sending = members.find(
    (member) => member.status === "sending",
  );

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const email = String(
      new FormData(event.currentTarget).get("email"),
    );
    event.currentTarget.reset();
    send({ id: crypto.randomUUID(), email });
  }

  return (
    <section aria-labelledby="team-heading">
      <h2 id="team-heading">Team</h2>
      <form onSubmit={submit}>
        <label htmlFor="invite-email">Email address</label>
        <input id="invite-email" name="email" type="email" required />
        <button type="submit">Send invite</button>
      </form>
      {failed ? (
        <div role="alert">
          <p>
            We couldn’t invite {failed.email}. The list is back as it
            was.
          </p>
          <button onClick={() => send(failed)}>Try again</button>
        </div>
      ) : null}
      <ul aria-label="Team members">
        {members.map((member) => (
          <li key={member.id}>
            {member.email} <span>{STATUS[member.status]}</span>
          </li>
        ))}
      </ul>
      <p role="status">
        {sending ? `Sending an invite to ${sending.email}` : ""}
      </p>
    </section>
  );
}

Submit an address and it joins the list as “Sending…” before the request has left, then turns to “Invited”. If the server refuses, the row goes, an alert names the address, and “Try again” clears the alert and resends the same invite with the same id; the reducer replaces by id, so the row never shows twice.

useActionState looks like a natural fit here, and for many forms it is. It runs its calls one after another, each receiving the previous result, which suits state that depends on the last answer. For invites it would hold the second address until the first had been answered, and the second row would wait with it. The version here has a cost of its own: React currently settles Actions that overlap together, so with two invites out, the first turns to “Invited” only when the second has its answer too.

Try it

Here is the same hook, drawn with Turtlekit components, against a simulated server that answers after a second and a half. Switch on the failure, send an invite, and watch the list roll back.

Invite a teammate

The server says no once, so you see the rollback.

  • priya@example.comActive
  • tomas@example.comInvited

Living with predictions

Predictions change what can go wrong, and most of the engineering is in those new edges.

Not every action deserves one

A prediction earns its place when the action nearly always succeeds, is cheap to take back, and has a result the browser already knows. Take away any one of those and waiting becomes the better answer:

Optimistic or not, in the billing app
ActionOptimistic?Why
Invite a teammateYesAlmost always succeeds; the row is easy to take back
Rename a projectYesThe result is exactly what was typed
Pay an invoiceNoMoney can’t be shown as moved until it has
Issue an invoice numberNoThe server decides the result

Retries that can’t double-invite

A request can succeed on the server and fail on its way back. Because the browser’s id travels as an idempotency key, the server answers a retry with the invite it already created, and the commit replaces any row with that id instead of appending. The list ends up right whichever answer arrives.

A reducer that runs more often than you think

The optimistic reducer reruns whenever its base value changes mid-Action, so keep it pure and cheap: append, replace or mark, and never fetch in it or sort a long list.

Pending in words, failure out loud

“Sending…” is written in the row rather than suggested by fading it, because opacity tells a screen reader nothing. A polite status region says an invite is on its way. The refusal is an alert carrying the address and a retry button, and the field is cleared at submit, ready for the next address.

Testing the moment between click and answer

The interesting state lasts only as long as the request, so the test controls the request. It hands the component a server whose promise it settles by hand, and asserts by role what people see in between:

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { TeamMembers } from "./team-members";
import type { Invite, Member } from "./team-invites";

const OWNER: Member = {
  id: "1",
  email: "priya@example.com",
  status: "active",
};

/** A server the test answers when it chooses. */
function heldServer() {
  let accept = () => {};
  let refuse = () => {};
  const sendInvite = (invite: Invite) =>
    new Promise<Member>((resolve, reject) => {
      accept = () => resolve({ ...invite, status: "invited" });
      refuse = () => reject(new Error("Network"));
    });
  return {
    sendInvite,
    accept: () => accept(),
    refuse: () => refuse(),
  };
}

async function invite(email: string) {
  const user = userEvent.setup();
  await user.type(
    screen.getByRole("textbox", { name: "Email address" }),
    email,
  );
  await user.click(
    screen.getByRole("button", { name: "Send invite" }),
  );
}

test("the invite shows at once, then settles", async () => {
  const server = heldServer();
  render(
    <TeamMembers initial={[OWNER]} sendInvite={server.sendInvite} />,
  );
  await invite("sam@example.com");
  expect(screen.getByText("sam@example.com")).toHaveTextContent(
    "Sending…",
  );
  expect(screen.getByRole("status")).toHaveTextContent(
    "Sending an invite to sam@example.com",
  );
  await act(async () => server.accept());
  expect(screen.getByText("sam@example.com")).toHaveTextContent(
    "Invited",
  );
});

test("a failed invite rolls back and says so", async () => {
  const server = heldServer();
  render(
    <TeamMembers initial={[OWNER]} sendInvite={server.sendInvite} />,
  );
  await invite("sam@example.com");
  await act(async () => server.refuse());
  expect(screen.queryByText("sam@example.com")).toBeNull();
  expect(screen.getAllByRole("listitem")).toHaveLength(1);
  expect(screen.getByRole("alert")).toHaveTextContent(
    "We couldn’t invite sam@example.com.",
  );
  expect(
    screen.getByRole("button", { name: "Try again" }),
  ).toBeVisible();
});

test("a retry clears the alert; one invite shows", async () => {
  const server = heldServer();
  render(
    <TeamMembers initial={[OWNER]} sendInvite={server.sendInvite} />,
  );
  await invite("sam@example.com");
  await act(async () => server.refuse());
  await userEvent.click(
    screen.getByRole("button", { name: "Try again" }),
  );
  expect(screen.queryByRole("alert")).toBeNull();
  expect(screen.getAllByText("sam@example.com")).toHaveLength(1);
  await act(async () => server.accept());
  expect(screen.getByText("sam@example.com")).toHaveTextContent(
    "Invited",
  );
});

Both tests pass. Delete the catch and the second fails on the missing alert.

What the server can and can’t hand over

The hook holds state, so its module opens with "use client". A Server Component can load the team and pass it in as initial, but it can pass sendInvite only as a Server Function, because a plain function can’t cross to the client. This page submits from onSubmit, which needs JavaScript. A form whose action is a Server Function, with useActionState’s permalink, can also be submitted before the page is interactive.

What React 19 took off your plate

Before React 19 the pattern meant a snapshot of the list, an immediate update and a manual restore in the catch, with every race left to you. Async transitions and useOptimistic make the rollback the default. The React Compiler changes nothing here; the work is in state and transitions, not in memoised values.

When waiting is the honest choice

Wait for the server when the action is costly or can’t be undone, like a payment or a deleted workspace. Wait, too, when it often fails on rules only the server knows, such as a workspace name already taken, or when the server decides the result, such as an invoice number.

In those cases the pending state belongs on the control: a spinner and a label on the button while the request runs, and the result once it is true.

How the demo is built with Turtlekit

Each row in the demo is an Item with a Badge whose tone follows the member’s status. The refusal is a Callout with tone="danger" and announce, so it is read out the moment it appears:

const TONE = {
  active: "success",
  invited: "info",
  sending: "neutral",
} as const;
const LABEL = {
  active: "Active",
  invited: "Invited",
  sending: "Sending…",
};

function MemberRow({ member }: { member: Member }) {
  return (
    <Item
      title={member.email}
      meta={
        <Badge tone={TONE[member.status]}>
          {LABEL[member.status]}
        </Badge>
      }
    />
  );
}

function InviteFailed({
  invite,
  onRetry,
}: {
  invite: Invite;
  onRetry: () => void;
}) {
  return (
    <Callout
      tone="danger"
      announce
      title="The invite didn’t send"
      actions={
        <Button
          size="1"
          variant="surface"
          tone="neutral"
          onClick={onRetry}
        >
          Try again
        </Button>
      }
    >
      {invite.email} isn’t on the team yet. The list is back as it
      was.
    </Callout>
  );
}

For the actions that should wait, Button takes a status of pending, success or error and draws the spinner and the label itself.

When the prediction misbehaves

When an optimistic list misbehaves, it is almost always one of these three.

“An optimistic state update occurred outside a Transition or Action”

The setter ran outside a transition, so the prediction flashed and vanished. Call the Action from an Action prop, such as a form’s action, or inside startTransition.

The prediction disappears before the server answers

The function passed to startTransition didn’t return the Action’s promise, so the transition ended early. Return it, or await it inside an async function.

The row disappears and nobody knows why

The rollback worked and the failure went unsaid. Catch the error in the Action, keep the invite that failed, and show it with a way to try again.

Fast, and honest about it

An optimistic screen makes a small promise every time someone acts on it. The speed is the visible part. The pattern stands or falls on what happens when the promise can’t be kept: whether the pending state was ever honest about being pending, and whether the refusal says what failed and how to try again. Get that right and people stop noticing the network.

The variant pattern in React

When a row’s pending, confirmed and failed states each need one look on every screen.

Compound components in React

When the invite form grows parts, such as a role picker, that share its state.

Reading async data with use() and Suspense in React

Start the request where the decision is made, read the promise with use(), and let Suspense and a boundary hold the wait and the failure.

useTransition and Actions in React

Keep the current screen while the next one loads, and let an async Action stay pending until its request settles, with no flags of your own.