Back to home

Coordinating Optimistic Updates in Next.js

13 Aug 2026

When building Single Page Application (SPA) experiences in Next.js, one of the most critical challenges is coordinating optimistic writes when user interactions overlap.

Overlapping writes happen constantly in real-world interfaces: a user drags multiple items in quick succession, renames a channel while moving another, or resizes calendar appointments before the previous save has resolved. Different frameworks handle this in various ways—such as React Router canceling interrupted requests and stale revalidations, or Solid Router tracking pending submissions.

In React 19, we can solve this cleanly by pairing useActionState with useOptimistic.

In this guide, we will examine how these two hooks work together to build an optimistic channel sidebar, and then scale the pattern across a component tree for an interactive calendar board.


Building an Optimistic Channel Sidebar

Consider a team chat application with a workspace rail, a channel sidebar, and a message view. Here is the workspace shell:

// app/(workspace)/layout.tsx
<WorkspaceRail />
<ChannelSidebar>
  <WorkspaceNav />
  <SearchButton />
  <ChannelList />
</ChannelSidebar>
<main>{children}</main>

The sidebar begins with ChannelList, which loads the saved channel groups in an async Server Component:

// features/channel/components/channel-list.tsx
export async function ChannelList() {
  const { groups, userId } = await getCurrentChannelLayout();
 
  return <ChannelNav groups={groups} key={userId} />;
}

Those groups become the initial state for the ChannelNav Client Component, where user interactions live:

// features/channel/components/channel-nav.tsx
"use client";
 
export function ChannelNav({ groups }: { groups: LayoutGroup[] }) {
  return (
    <nav aria-label="Channels">
      {groups.map((group) => (
        <div key={group.name}>
          <p>{group.name}</p>
          {group.channels.map((channel) => (
            <ChannelLink channel={channel} key={channel.id} />
          ))}
        </div>
      ))}
    </nav>
  );
}

We want the channel sidebar to update immediately when someone moves a channel or edits a group, while ensuring the layout changes save in the exact order they occurred.


Saving the Channel Layout in a Transition

To persist the layout, we create a Server Function that writes the updated positions to our database:

// features/channel/channel-actions.ts
"use server";
 
export async function saveChannelLayout(groups: LayoutGroup[]) {
  const user = await verifyAuth();
 
  for (const [position, group] of groups.entries()) {
    await prisma.channelGroup.upsert({
      create: { name: group.name, position, userId: user.id },
      update: { position },
      where: { userId_name: { name: group.name, userId: user.id } },
    });
  }
}

With the Server Function in place, we can call saveChannelLayout inside a Transition in ChannelNav. Wrapping the call in useTransition sets isPending while the save runs:

// features/channel/components/channel-nav.tsx
export function ChannelNav({ groups }: { groups: LayoutGroup[] }) {
  const [isPending, startTransition] = useTransition();
 
  function saveChange(nextGroups: LayoutGroup[]) {
    startTransition(async () => {
      await saveChannelLayout(nextGroups);
    });
  }
 
  return <nav>{/* render groups */}</nav>;
}

The Overlap Problem

Next.js dispatches and awaits Server Actions sequentially, so writes do not race across the network. However, ChannelNav calculates nextGroups before the save enters the queue.

If someone makes a second change before the first save finishes, the second nextGroups payload is built from the layout as it was before either change began. When the second write lands last on the server, it drops the earlier change from the saved layout.

We could disable the UI while isPending is true, but that makes dragging and editing feel unresponsive. Instead, we want later changes to build on the result of the save before them.


Building on the Previous Layout with useActionState

The useActionState hook stores the result of an Action and automatically queues calls made through its dispatcher:

const [state, dispatchAction, isPending] = useActionState(
  async (previousState, actionPayload) => {
    return nextState;
  },
  initialState,
);

If we dispatch several changes, React waits for one callback to resolve before passing its return value into the next. We can await the save inside the callback, and isPending remains true until all saves have completed.

To build a change on top of the previous save, our mutation needs the previous groups and a LayoutChange action describing what happened. We extract this logic into a pure reducer:

// features/channel/utils/channel-layout-reducer.ts
export function channelLayoutReducer(
  groups: LayoutGroup[],
  change: LayoutChange,
): LayoutGroup[] {
  switch (change.type) {
    case "move": {
      const next = groups.map((group) => ({
        ...group,
        channels: group.channels.filter((c) => c.id !== change.channelId),
      }));
 
      const moved = groups
        .flatMap((g) => g.channels)
        .find((c) => c.id === change.channelId);
 
      const target = next.find((g) => g.name === change.toGroup);
      if (!moved || !target) return groups;
 
      const index = Math.max(
        0,
        Math.min(change.toIndex, target.channels.length),
      );
 
      target.channels.splice(index, 0, moved);
      return next;
    }
    default:
      return groups;
  }
}

Now we can use channelLayoutReducer inside saveChannelLayout on the server:

// features/channel/channel-actions.ts
"use server";
 
import {
  type LayoutChange,
  type LayoutGroup,
  channelLayoutReducer,
} from "./utils/channel-layout-reducer";
 
export async function saveChannelLayout(
  groups: LayoutGroup[],
  change: LayoutChange,
): Promise<LayoutGroup[]> {
  await verifyAuth();
  const next = channelLayoutReducer(groups, change);
 
  // Write updated layout to database...
  return next;
}

Once the write succeeds, the returned layout becomes the base state for the next queued update. In ChannelNav, we connect saveChannelLayout to useActionState:

// features/channel/components/channel-nav.tsx
"use client";
 
import { startTransition, useActionState } from "react";
 
export function ChannelNav({
  groups: initialGroups,
}: {
  groups: LayoutGroup[];
}) {
  const [groups, dispatch] = useActionState(saveChannelLayout, initialGroups);
 
  function runChange(change: LayoutChange) {
    startTransition(() => {
      dispatch(change);
    });
  }
 
  return <nav>{/* render groups */}</nav>;
}

Because Huddle dispatches changes from drag-and-drop and menu interactions rather than native forms, we call startTransition explicitly inside runChange. React waits for the previous save and passes its returned layout into saveChannelLayout.


Showing Layout Changes with useOptimistic

The saves are now strictly ordered, but the sidebar currently renders groups, which only updates after the server finishes writing to the database. Moving a channel still feels delayed.

We can pair useActionState with useOptimistic to show the expected layout immediately while the server action is in flight:

// features/channel/components/channel-nav.tsx
"use client";
 
import { startTransition, useActionState, useOptimistic } from "react";
import { channelLayoutReducer } from "../utils/channel-layout-reducer";
 
 
export function ChannelNav({
  groups: initialGroups,
}: {
  groups: LayoutGroup[];
}) {
  const [groups, dispatch] = useActionState(saveChannelLayout, initialGroups);
 
  const [optimisticGroups, addOptimistic] = useOptimistic(
    groups,
    channelLayoutReducer,
  );
 
  function runChange(change: LayoutChange) {
    startTransition(() => {
      addOptimistic(change);
      dispatch(change);
    });
  }
 
  return <nav>{/* render optimisticGroups */}</nav>;
}

When runChange fires, the sidebar renders optimisticGroups immediately. In the background, dispatch queues and saves the change. Once the save completes, groups catches up to what is already on screen.


Rolling Back Failed Layout Changes

An optimistic mutation can always fail due to network errors or database constraints. When that happens, we want to notify the user and restore the sidebar to the last confirmed state.

We handle errors inside the callback passed to useActionState:

// features/channel/components/channel-nav.tsx
"use client";
 
import { toast } from "sonner";
 
export function ChannelNav({
  groups: initialGroups,
}: {
  groups: LayoutGroup[];
}) {
  const [groups, dispatch] = useActionState(
    async (previousGroups: LayoutGroup[], change: LayoutChange) => {
      try {
        return await saveChannelLayout(previousGroups, change);
      } catch {
        toast.error("Could not save channel layout. Try again.");
        return previousGroups;
      }
    },
    initialGroups,
  );
 
  const [optimisticGroups, addOptimistic] = useOptimistic(
    groups,
    channelLayoutReducer,
  );
 
  function runChange(change: LayoutChange) {
    startTransition(() => {
      addOptimistic(change);
      dispatch(change);
    });
  }
 
  return (
    <nav aria-label="Channels">
      {optimisticGroups.map((group) => (
        <div key={group.name}>
          <p>{group.name}</p>
          {group.channels.map((channel) => (
            <ChannelLink channel={channel} key={channel.id} />
          ))}
        </div>
      ))}
    </nav>
  );
}

While the action is pending, useOptimistic displays the temporary layout. Once the action finishes, React discards the temporary state and renders the confirmed state from useActionState. If the write fails, previousGroups is returned, moving the sidebar back without needing to compute a manual reverse diff.


Scaling Across the Component Tree: The Calendar Board

Now consider a calendar and booking application with week and month views:

// app/(workspace)/calendar/[date]/page.tsx
<main>
  <CalendarHeader date={date} view={calendarView} />
  {calendarView === "month" ? (
    <CalendarMonth date={date} />
  ) : (
    <CalendarWeek date={date} />
  )}
</main>

For the week view, CalendarWeek fetches events and calendars in a Server Component:

// features/calendar/components/calendar-week.tsx
export async function CalendarWeek({ date }: { date: string }) {
  const [week, calendars] = await Promise.all([
    getCalendarWeek(date),
    getCalendars(),
  ]);
 
  return (
    <CalendarBoard
      calendars={calendars}
      days={week.days}
      events={week.events}
    />
  );
}

From here, CalendarBoard renders the interactive week grid. We want creations, moves, and resizes on this grid to appear instantly while writes persist in order.

Adding an Action Queue to CalendarBoard

To serialize rapid mutations (such as moving an event and immediately resizing it), we represent all interactions as EventChange objects dispatched to a single Server Function:

// features/calendar/calendar-actions.ts
"use server";
 
export async function saveEventChange(change: EventChange) {
  const user = await verifyAuth();
 
  switch (change.type) {
    case "move":
      return await prisma.calendarEvent.update({
        where: { id: change.sourceId, userId: user.id },
        data: { day: change.day, start: change.start },
      });
    case "resize":
      return await prisma.calendarEvent.update({
        where: { id: change.sourceId, userId: user.id },
        data: { duration: change.duration },
      });
  }
}

Unlike our channel layout, saving an event only mutates a single record rather than rewriting the entire structure. The Server Function returns the updated row or an error. The action state can be void:

// features/calendar/components/calendar-board.tsx
"use client";
 
import { startTransition, useActionState, useOptimistic } from "react";
import { eventChangeReducer } from "../utils/event-change-reducer";
 
 
export function CalendarBoard({ events, days, calendars }: CalendarBoardProps) {
  const [, dispatch] = useActionState(async (_: void, change: EventChange) => {
    await saveEventChange(change);
  }, undefined);
 
  const [optimisticEvents, addOptimistic] = useOptimistic(
    events,
    eventChangeReducer,
  );
 
  function mutate(change: EventChange) {
    startTransition(() => {
      addOptimistic(change);
      dispatch(change);
    });
  }
 
  return <div className="calendar-grid">{/* render optimisticEvents */}</div>;
}

Sharing Event Changes Across Boundaries with Context

In a full application, CalendarBoard cannot be the sole owner of this state. The month view renders its own CalendarMonthBoard, the header contains a NewEventButton, and an EventPopover handles edits and deletions.

Because Server Components sit between the header and the boards, passing mutate down through props would require converting all intermediary Server Components into Client Components.

Instead of lifting the entire calendar into a monolithic Client Component, we wrap the views in a CalendarEventsProvider. Client Components beneath it can access the context even with Server Components situated in between:

// app/(workspace)/calendar/[date]/page.tsx
<CalendarEventsProvider>
  <CalendarHeader date={date} view={calendarView} />
  {calendarView === "month" ? (
    <CalendarMonth date={date} />
  ) : (
    <CalendarWeek date={date} />
  )}
</CalendarEventsProvider>

Storing Changes Instead of Events

The action queue moves into the provider cleanly because saveEventChange only requires an EventChange. However, useOptimistic requires an initial base state to calculate its next return value.

CalendarWeek and CalendarMonth fetch different date ranges on the server below the provider. The provider does not have direct access to those events.

To solve this, the provider stores a list of pending changes instead of the events themselves. A list of changes starts empty and gets appended to, so the provider can hold it without needing server data:

// providers/calendar-events-provider.tsx
"use client";
 
import { toast } from "sonner";
import {
  createContext,
  startTransition,
  useActionState,
  useContext,
  useOptimistic,
} from "react";
 
 
export function CalendarEventsProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [, dispatch] = useActionState(async (_: void, change: EventChange) => {
    const result = await saveEventChange(change);
    if (result?.error) {
      toast.error(result.error);
    }
  }, undefined);
 
  const [pendingChanges, addOptimistic] = useOptimistic<
    EventChange[],
    EventChange
  >([], (changes, change) => [...changes, change]);
 
  function mutate(change: EventChange) {
    startTransition(() => {
      addOptimistic(change);
      dispatch(change);
    });
  }
 
  return (
    <CalendarStateContext.Provider value={{ pendingChanges }}>
      <CalendarDispatchContext.Provider value={mutate}>
        {children}
      </CalendarDispatchContext.Provider>
    </CalendarStateContext.Provider>
  );
}

Now, a custom hook replays pendingChanges over whatever events a board received from the server:

// features/calendar/hooks/use-optimistic-events.ts
"use client";
 
import { useCalendarEvents } from "@/providers/calendar-events-provider";
import { eventChangeReducer } from "../utils/event-change-reducer";
 
 
export function useOptimisticEvents(events: CalendarEvent[]) {
  const { pendingChanges } = useCalendarEvents();
 
  return pendingChanges.reduce(eventChangeReducer, events);
}

Inside CalendarBoard, we call useOptimisticEvents before rendering:

// features/calendar/components/calendar-board.tsx
"use client";
 
import { useOptimisticEvents } from "../hooks/use-optimistic-events";
 
export function CalendarBoard({ events, days }: CalendarBoardProps) {
  const optimisticEvents = useOptimisticEvents(events);
 
  return <div className="calendar-grid">{/* render optimisticEvents */}</div>;
}

An EventPopover can now read the dispatch context directly to trigger edits and deletions without prop-drilling:

// features/calendar/components/event-popover.tsx
"use client";
 
import { useCalendarDispatch } from "@/providers/calendar-events-provider";
 
export function EventPopover({ event, onClose }: EventPopoverProps) {
  const mutate = useCalendarDispatch();
 
  function remove() {
    mutate({ sourceId: event.id, type: "delete" });
    onClose();
  }
 
  return (
    <div>
      <button onClick={remove}>Delete</button>
    </div>
  );
}

When to Reach for a Client Data Library

For channel reordering and calendar interactions, confirmed data lives in Server Components and the optimistic state automatically clears when the transition finishes.

However, when data updates autonomously—such as chat messages arriving from other users while a window is open—relying solely on Server Action transitions is not enough. In those scenarios, reach for client-side libraries like SWR or TanStack Query.

In a hybrid setup, Server Components seed the initial cache on the server, and the client hook takes over background polling:

// features/message/components/message-thread.tsx
export async function MessageThread({ channelId }: { channelId: string }) {
  const user = await getCurrentUser();
  const initialMessages = await getMessages(channelId);
 
  return (
    <SWRConfig value={{ fallback: { [channelId]: initialMessages } }}>
      <MessageList channelId={channelId} userId={user.id} />
    </SWRConfig>
  );
}
// features/message/hooks/use-messages.ts
"use client";
 
import useSWR from "swr";
 
export function useMessages(channelId: string) {
  return useSWR(`/api/channels/${channelId}/messages`, fetcher, {
    refreshInterval: 5000,
  });
}

Conclusion

The power of combining useActionState and useOptimistic is architectural: Server Components continue to own the canonical data layer.

Client state is not used as a long-lived duplicate database in the browser. Instead, client state exists only as a lightweight coordinator:

  1. useOptimistic handles instant visual feedback while actions are in flight.
  2. useActionState sequences mutations to eliminate race conditions.
  3. The server response confirms the canonical state and automatically clears temporary optimistic diffs.

By separating the mutation queue from the rendering tree, you deliver an interface that feels as responsive as a local desktop application while preserving the simplicity and security of server-driven state.