Skip to content

Building Design Components with Action Props using Async React

Published: at 17:00

React Conf 2025 established the concept of Async React, introducing three layers: async design, async router, and async data. In this post, we’ll put the design layer into practice by building two components from scratch (a tab list and an inline editable text field), progressively improving the UX during async work while keeping optimistic updates and loading states internal so consumers stay simple.

For an overview or refresher on the Async React primitives, check out my article The next era of React has arrived on LogRocket. For another example, see my previous post where we built a reusable Select component with this pattern before Async React gave us the terminology.

Table of contents

Open Table of contents

Actions and the Action Prop Pattern

The components in this post rely on two Async React primitives:

The React docs on exposing action props describe a pattern where components accept action functions as props and run them inside transitions internally. The refreshed useOptimistic docs expand on this by combining optimistic state with action props.

The basic pattern looks like this:

function DesignComponent({ value, action }) {
  const [optimistic, setOptimistic] = useOptimistic(value);
  const [isPending, startTransition] = useTransition();

  function handleChange(newValue) {
    startTransition(async () => {
      setOptimistic(newValue);
      await action(newValue);
    });
  }
  // ...
}

The component owns the transition, the optimistic state, and the pending UI. The consumer just passes a value and an action.

In the future, component libraries can ship components with action props built in, but until then (or for your own components), we can build them ourselves.

Example 1: TabList

Let’s build a reusable tab list component. A basic version might look like this:

type TabListProps = {
  tabs: { label: string; value: string }[];
  activeTab: string;
  onChange: (value: string) => void;
};

export function TabList({ tabs, activeTab, onChange }: TabListProps) {
  return (
    <div>
      {tabs.map(tab => (
        <button
          key={tab.value}
          onClick={() => onChange(tab.value)}
          className={tab.value === activeTab ? "active" : ""}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

A consumer might use this to filter content via the URL:

import { useRouter, useSearchParams } from "next/navigation";

function FilterTabs() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const currentTab = searchParams.get("filter") ?? "all";

  return (
    <TabList
      tabs={tabs}
      activeTab={currentTab}
      onChange={value => router.push(`/?filter=${value}`)}
    />
  );
}

When onChange triggers async work (like router.push in Next.js, where a Server Component re-renders with new search params), the activeTab won’t update until the work completes. On slow networks, the user clicks a tab and nothing happens, getting no feedback that their interaction was registered.

Tracking the Pending State

Let’s add a changeAction prop and track the pending state with useTransition(). The “Action” suffix signals that the function runs inside a transition. It accepts both sync and async functions (void | Promise<void>), so the consumer can pass either without needing their own startTransition wrapper:

type TabListProps = {
  tabs: { label: string; value: string }[];
  activeTab: string;
  changeAction: (value: string) => void | Promise<void>;
};

export function TabList({ tabs, activeTab, changeAction }: TabListProps) {
  const [isPending, startTransition] = useTransition();

  function handleTabChange(value: string) {
    startTransition(async () => {
      await changeAction(value);
    });
  }

  return (
    <div>
      {tabs.map(tab => (
        <button
          key={tab.value}
          onClick={() => handleTabChange(tab.value)}
          className={tab.value === activeTab ? "active" : ""}
        >
          {tab.label}
        </button>
      ))}
      {isPending && <Spinner />}
    </div>
  );
}

Now a spinner shows while the Action is pending. Because the loading indicator lives inside the component, the feedback is localized to the interaction, which is a much better experience than a global loading bar. But the active tab still doesn’t update immediately.

Adding Optimistic Updates

This is where useOptimistic() comes in. It creates a temporary client-side state derived from the activeTab prop that we can set inside the Action before it completes:

export function TabList({ tabs, activeTab, changeAction }: TabListProps) {
  const [optimisticTab, setOptimisticTab] = useOptimistic(activeTab);
  const [isPending, startTransition] = useTransition();

  function handleTabChange(value: string) {
    startTransition(async () => {
      setOptimisticTab(value);
      await changeAction(value);
    });
  }

  return (
    <div>
      {tabs.map(tab => (
        <button
          key={tab.value}
          onClick={() => handleTabChange(tab.value)}
          className={tab.value === optimisticTab ? "active" : ""}
        >
          {tab.label}
        </button>
      ))}
      {isPending && <Spinner />}
    </div>
  );
}

Now the tab switches instantly when clicked. The optimisticTab holds the new value while the Action is pending, and once the changeAction completes and activeTab updates from the parent, it settles to the new source of truth.

Because everything runs inside a transition, React coordinates it all into a single stable commit, avoiding intermediate renders and UI flickering. The consumer just passes values and callbacks, and the design component handles the async implementation and the UI.

The Final TabList

The consumer might also need a regular onChange for synchronous side effects outside the Action (for example, logging or local state), so the final version accepts both. The onChange fires synchronously before the transition starts.

Here is the final TabList:

import { useOptimistic, useTransition } from "react";

type TabListProps = {
  tabs: { label: string; value: string }[];
  activeTab: string;
  changeAction?: (value: string) => void | Promise<void>;
  onChange?: (value: string) => void;
};

export function TabList({
  tabs,
  activeTab,
  changeAction,
  onChange,
}: TabListProps) {
  const [optimisticTab, setOptimisticTab] = useOptimistic(activeTab);
  const [isPending, startTransition] = useTransition();

  function handleTabChange(value: string) {
    onChange?.(value);
    startTransition(async () => {
      setOptimisticTab(value);
      await changeAction?.(value);
    });
  }

  return (
    <div>
      {tabs.map(tab => (
        <button
          key={tab.value}
          onClick={() => handleTabChange(tab.value)}
          className={tab.value === optimisticTab ? "active" : ""}
        >
          {tab.label}
        </button>
      ))}
      {isPending && <Spinner />}
    </div>
  );
}

In a real project, you’d use proper tab primitives for accessibility and styling. You can see a full implementation of TabList on GitHub.

Usage: PostTabs in a Blog Dashboard

Let’s say we wanted to filter blog posts by status in a blog admin panel, where each tab updates a search param and the page re-renders server-side with the filtered data.

Here is the consumer using the finished TabList:

"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { TabList } from "@/components/design/TabList";

const tabs = [
  { label: "All", value: "all" },
  { label: "Published", value: "published" },
  { label: "Drafts", value: "drafts" },
  { label: "Archived", value: "archived" },
];

export function PostTabs() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const currentTab = searchParams.get("filter") ?? "all";

  return (
    <TabList
      tabs={tabs}
      activeTab={currentTab}
      changeAction={value => router.push(`/dashboard?filter=${value}`)}
    />
  );
}

Optimistic updates, the pending spinner, and rollback are all handled internally by TabList. The tabs switch instantly, and the post list, wrapped in Suspense, stays visible while the new filtered data loads in the background. You can try it out on next16-async-react-blog.

PostTabs filtering blog posts by status in the dashboard

Example 2: EditableText

Let’s apply the same pattern to an inline editable text field. The user clicks to edit, types a value, and commits with Enter or a save button.

A basic version might look like this:

type EditableTextProps = {
  value: string;
  onSave: (value: string) => void | Promise<void>;
};

export function EditableText({ value, onSave }: EditableTextProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  function handleCommit() {
    setIsEditing(false);
    onSave(draft);
  }

  function handleCancel() {
    setDraft(value);
    setIsEditing(false);
  }

  if (isEditing) {
    return (
      <input
        value={draft}
        onChange={e => setDraft(e.target.value)}
        onKeyDown={e => {
          if (e.key === "Enter") handleCommit();
          if (e.key === "Escape") handleCancel();
        }}
        autoFocus
      />
    );
  }

  return (
    <button
      onClick={() => {
        setDraft(value);
        setIsEditing(true);
      }}
    >
      {value || "Click to edit..."}
    </button>
  );
}

A consumer might use this to persist a value asynchronously, with custom formatting for display:

function EditablePrice({ price }) {
  return (
    <EditableText
      value={price}
      onSave={async newValue => {
        await savePrice(newValue);
      }}
      displayValue={formatCurrency(Number(price))}
    />
  );
}

When onSave is async, the displayed text doesn’t update until it completes and the parent re-renders with the new value prop. The formatted displayValue is also stale until then, since it’s derived from the parent’s price. On slow connections, the user saves and sees stale text with no feedback: the same problem we had with TabList.

Adding Optimistic State and Pending Indicators

Just like with TabList, we rename the callback to action (signaling it runs inside a transition) and add useTransition and useOptimistic:

export function EditableText({ value, action }: EditableTextProps) {
  const [isPending, startTransition] = useTransition();
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  function handleCommit() {
    setIsEditing(false);
    if (draft === optimisticValue) return;
    startTransition(async () => {
      setOptimisticValue(draft);
      await action(draft);
    });
  }

  function handleCancel() {
    setDraft(optimisticValue);
    setIsEditing(false);
  }
  ...
}

Now we get the same benefits as TabList: the value updates instantly, isPending drives a spinner, and failures revert automatically. Note that handleCancel resets to optimisticValue rather than value, so the draft reflects the latest pending save if one is still in flight.

Formatting Optimistic State with displayValue

The EditablePrice consumer above passes a static displayValue derived from the parent’s price prop. But since the optimistic state lives inside the component, that static value won’t reflect an optimistic update. One solution is to let displayValue also accept a function that receives the current value and returns formatted output:

<EditableText
  value={price}
  action={savePrice}
  displayValue={value => formatCurrency(Number(value))} // renders "$70,000"
/>

Here is how the prop is typed:

type EditableTextProps = {
  value: string;
  action: (value: string) => void | Promise<void>;
  onChange?: (value: string) => void;
  displayValue?: ((value: string) => React.ReactNode) | React.ReactNode;
};

It accepts either a function or a static ReactNode. Inside the component, we resolve it against the optimistic value:

const resolvedDisplay = optimisticValue
  ? typeof displayValue === "function"
    ? displayValue(optimisticValue)
    : (displayValue ?? optimisticValue)
  : null;

When displayValue is a function, the component calls it with the optimistic value, so the formatted display updates immediately on commit without the consumer needing access to the optimistic state.

The Final EditableText

Like TabList, the final version also accepts an onChange callback for synchronous side effects outside the transition.

Here is the final implementation:

import { useOptimistic, useState, useTransition } from "react";

type EditableTextProps = {
  value: string;
  displayValue?: ((value: string) => React.ReactNode) | React.ReactNode;
  onChange?: (value: string) => void;
  action: (value: string) => void | Promise<void>;
};

export function EditableText({
  value,
  displayValue,
  action,
  onChange,
}: EditableTextProps) {
  const [isPending, startTransition] = useTransition();
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(value);

  function handleCommit() {
    setIsEditing(false);
    if (draft === optimisticValue) return;
    onChange?.(draft);
    startTransition(async () => {
      setOptimisticValue(draft);
      await action(draft);
    });
  }

  function handleCancel() {
    /* ... */
  }

  const resolvedDisplay = optimisticValue
    ? typeof displayValue === "function"
      ? displayValue(optimisticValue)
      : (displayValue ?? optimisticValue)
    : null;

  if (isEditing) {
    return (
      <input
        value={draft}
        onChange={e => setDraft(e.target.value)}
        onKeyDown={e => {
          if (e.key === "Enter") handleCommit();
          if (e.key === "Escape") handleCancel();
        }}
        autoFocus
      />
      // ... save/cancel buttons
    );
  }

  return (
    <>
      <button
        onClick={() => {
          setDraft(optimisticValue);
          setIsEditing(true);
        }}
      >
        {resolvedDisplay || "Click to edit..."}
      </button>
      {isPending && <Spinner />}
    </>
  );
}

In a real project, you could also extend input attributes via React.ComponentProps<"input"> and add blur handling. You can see a full implementation of EditableText on GitHub.

Usage: RevenueGoal in a Sales Dashboard

Let’s say we wanted to let users edit a revenue goal inline in a sales dashboard, where the raw number is stored as a string but displayed as formatted currency.

Here is the consumer using the finished EditableText:

"use client";

import { use } from "react";
import { saveRevenueGoal } from "@/data/actions/preferences";
import { formatCurrency } from "@/lib/utils";
import { EditableText } from "./design/EditableText";

export function RevenueGoal({
  goalPromise,
}: {
  goalPromise: Promise<number | null>;
}) {
  const goal = use(goalPromise);

  return (
    <EditableText
      value={goal?.toString() ?? ""}
      action={saveRevenueGoal}
      displayValue={value => formatCurrency(Number(value))}
      prefix="$"
      type="number"
      placeholder="Set a target..."
    />
  );
}

The consumer passes the current value, a Server Function as the action, and a displayValue formatter for currency. The rest is handled internally. You can try it out on next16-chart-dashboard.

RevenueGoal inline editing with formatted currency display

Key Takeaways

Conclusion

The action props pattern applies to any interactive component: selects, checkboxes, search inputs, toggles. Ideally, this logic should live in the component libraries we already use. Async React has already standardized much of the async routing and loading layers, and the Async React Working Group is now working with UI frameworks to bring the same coordination to the design layer, but until then we can build our own.

I hope this post has been helpful. Please let me know if you have any questions or comments, and follow me on Bluesky or X for more updates. Happy coding! 🚀

If you would like to support my work, you can Buy me a coffee