Deepen PracticeOrdered learning track

Orchestrating Optimistic Workflows

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 094

Orkestrasi optimistic workflows di React: latency compensation, optimistic overlay, pending graph, rollback, compensation, temp IDs, conflict handling, TanStack Query integration, React useOptimistic, state machines, testing, observability, dan failure modes.

5 min read960 words
PrevNext
Lesson 94123 lesson track68–101 Deepen Practice
#react#hooks#optimistic-ui#server-state+5 more

Part 094 — Orchestrating Optimistic Workflows

Optimistic UI sering diajarkan sebagai trik kecil:

update UI dulu
request ke server
kalau gagal rollback

Itu benar untuk demo.

Tetapi di production, optimistic workflow adalah sistem state yang punya banyak edge case:

temporary id
mutation ordering
rollback context
partial failure
conflict
permission drift
server validation
duplicate command
offline replay
concurrent mutation
cache invalidation
pagination drift
audit/logging
compensation action

Optimistic UI bukan sekadar membuat UI terasa cepat.

Optimistic UI adalah latency compensation protocol.


1. Mental Model

Server adalah source of truth.

Client optimistic state adalah proyeksi sementara.

Prinsip:

Optimistic state is not truth.
It is a pending hypothesis.

Jika UI tidak membedakan truth dan hypothesis, bug akan muncul.


2. When Optimistic UI Is Appropriate

Tidak semua action layak optimistic.

ActionOptimistic?Reason
Like/unlikeYesLow risk, easy rollback
Toggle favoriteYesUser intent simple, reversible
Mark notification readYesLow criticality
Add commentOftenNeeds temp id and failure state
Rename itemOftenNeeds conflict/validation handling
Delete itemSometimesNeeds undo or recovery
Submit paymentUsually noHigh risk, authority strict
Approve regulatory caseUsually noAudit/permission risk
Send legal noticeUsually noIrreversible/high consequence
Save draftOftenCan be pending/conflict-aware

Decision questions:

Is the command reversible?
Can failure be explained clearly?
Can rollback restore user trust?
Can duplicate command cause harm?
Can permission/precondition change server-side?
Is audit/legal correctness more important than perceived speed?

For high-consequence workflows, prefer pessimistic command with responsive pending UI.


3. Optimistic State Topology

Optimistic workflow touches several state layers.

Typical state:

base server data
optimistic patch
pending command id
rollback context
temporary id mapping
error state
retry intent
conflict state

Do not put all of this into a random component useState cluster.


4. Optimistic Overlay vs Cache Mutation

There are two main strategies.

4.1 Optimistic overlay

Keep server data untouched, layer optimistic projection at render time.

type Todo = {
  id: string;
  title: string;
  completed: boolean;
};

type PendingPatch = {
  id: string;
  todoId: string;
  completed: boolean;
};

function applyPendingPatches(todos: Todo[], patches: PendingPatch[]) {
  return todos.map((todo) => {
    const patch = patches.find((item) => item.todoId === todo.id);
    return patch ? { ...todo, completed: patch.completed } : todo;
  });
}

Pros:

clear separation between truth and hypothesis
easier rollback
safe for high-risk projection

Cons:

read model code becomes more complex
all readers must apply overlay consistently
harder across many screens

4.2 Cache mutation

Patch server-state cache optimistically.

queryClient.setQueryData(["todos"], (old: Todo[] | undefined) => {
  if (!old) return old;
  return old.map((todo) =>
    todo.id === todoId ? { ...todo, completed: true } : todo,
  );
});

Pros:

all subscribers update immediately
simple for shared server-state cache
matches library ecosystem

Cons:

rollback must be exact
concurrent mutations are harder
partial lists/projections can drift
mistakes corrupt apparent truth

Use cache mutation for low/medium-risk server-state actions.

Use overlay strategy when correctness and explainability matter more.


5. Anatomy of an Optimistic Mutation

A production optimistic command has phases.

idle
→ optimistic-applied
→ request-pending
→ succeeded/reconciled
→ failed/rolled-back
→ failed/compensated
→ conflict

Model explicitly:

type OptimisticCommandStatus =
  | "pending"
  | "succeeded"
  | "failed"
  | "rolled-back"
  | "conflict";

type OptimisticCommand<TPatch, TRollback> = {
  commandId: string;
  kind: string;
  patch: TPatch;
  rollback: TRollback;
  status: OptimisticCommandStatus;
  createdAt: number;
};

For simple apps, TanStack Query mutation lifecycle may be enough.

For complex workflows, keep a mutation registry.


6. React useOptimistic

useOptimistic lets component display a different state while async action is underway.

function CommentList({ comments }: { comments: Comment[] }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (currentComments, optimisticComment: Comment) => [
      ...currentComments,
      optimisticComment,
    ],
  );

  async function submitComment(formData: FormData) {
    const body = String(formData.get("body") ?? "");

    addOptimisticComment({
      id: `temp:${crypto.randomUUID()}`,
      body,
      status: "sending",
    });

    await createComment({ body });
  }

  return (
    <form action={submitComment}>
      <CommentItems comments={optimisticComments} />
      <input name="body" />
      <button>Send</button>
    </form>
  );
}

Use it when:

optimistic projection is local to this render boundary
action lifecycle is close to the component
rollback can be handled by server result/base state update
state does not need global pending registry

Do not use it as a replacement for server-state cache management when many screens need the same optimistic projection.


7. TanStack Query Optimistic Update

TanStack Query commonly uses onMutate for optimistic cache patching.

function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: toggleTodo,

    onMutate: async ({ todoId, completed }) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

      queryClient.setQueryData<Todo[]>(["todos"], (old) => {
        if (!old) return old;

        return old.map((todo) =>
          todo.id === todoId ? { ...todo, completed } : todo,
        );
      });

      return { previousTodos };
    },

    onError: (_error, _variables, context) => {
      queryClient.setQueryData(["todos"], context?.previousTodos);
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

Key invariant:

onMutate captures rollback context before patch.
onError restores rollback context.
onSettled reconciles with server truth.

But this simple rollback can break with concurrent mutations.


8. Concurrent Mutations

Consider two commands:

T0: todo.completed = false
T1: user toggles to true → optimistic true
T2: user toggles to false → optimistic false
T3: first request fails
T4: second request succeeds

Naive rollback of first request restores old false, which happens to be okay here.

But for different patches, naive rollback can overwrite newer intent.

Better model:

base server snapshot + ordered optimistic patches
function projectTodo(base: Todo, patches: TodoPatch[]) {
  return patches.reduce((todo, patch) => {
    if (patch.todoId !== todo.id) return todo;

    switch (patch.type) {
      case "set-completed":
        return { ...todo, completed: patch.completed };
      case "rename":
        return { ...todo, title: patch.title };
      default:
        return todo;
    }
  }, base);
}

On failure:

remove failed patch
reproject remaining patches over latest base

This is more robust than snapshot rollback for complex concurrent flows.


9. Temporary IDs

Creating items optimistically needs temporary IDs.

type ClientId = `client:${string}`;
type ServerId = string;

type DraftComment = {
  id: ClientId | ServerId;
  body: string;
  status: "sending" | "sent" | "failed";
};

Flow:

Rules:

temp ID must never be confused with server ID
temp ID must be stable while pending
server response must reconcile temp ID to server ID
related optimistic records must use temp ID mapping
failed temp record should show retry/delete state, not vanish silently

Use client-generated commandId or idempotency key when backend supports it.


10. Reconciliation

Success response is not just “mark succeeded”.

Server may return:

canonical id
canonical timestamp
normalized text
computed totals
permission-filtered fields
new version/etag
related aggregate changes
server validation warnings

Reconciliation must merge server truth.

function reconcileCreatedComment(
  comments: Comment[],
  input: { tempId: string; serverComment: Comment },
) {
  return comments.map((comment) =>
    comment.id === input.tempId ? input.serverComment : comment,
  );
}

Do not keep optimistic createdAt if server sends canonical time.

Do not keep optimistic count if server sends authoritative aggregate.


11. Rollback vs Compensation

Rollback means restoring previous apparent state.

Compensation means applying another action to correct user-visible state.

CaseBetter response
Like failedRollback
Add comment failedKeep failed item with retry
Delete failedRestore item
Delete succeeded but undo clickedCompensation command: restore
Payment failedNo optimistic state; show failure
Approval conflictConflict state; ask user to reload/review

For user trust, sometimes silent rollback is worse than visible failed state.

Example failed comment:

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <article>
      <p>{comment.body}</p>
      {comment.status === "sending" && <small>Sending...</small>}
      {comment.status === "failed" && (
        <small>
          Failed to send. <button>Retry</button>
        </small>
      )}
    </article>
  );
}

12. Conflict Handling

Conflict happens when optimistic assumption no longer matches server truth.

Examples:

record version changed
user lost permission
entity was deleted by another user
validation rule changed
quota exceeded
approval state moved forward

Do not call this just “error”.

Model conflict separately.

type MutationError =
  | { type: "network"; retryable: true }
  | { type: "validation"; fieldErrors: Record<string, string> }
  | { type: "conflict"; serverVersion: number }
  | { type: "permission" }
  | { type: "unknown" };

Conflict UX options:

reload server truth
show compare/merge view
discard local optimistic patch
retry command against latest version
ask user to resolve manually

For regulated workflows, prefer explicit conflict state over silent reapply.


13. Pending Graph

In complex screens, pending is not one boolean.

row A deleting
row B saving
list reorder pending
bulk action pending
counter invalidation pending
toast undo timer pending

Use command IDs.

type PendingRegistry = Record<
  string,
  {
    commandId: string;
    entityKey: string;
    kind: string;
    status: "pending" | "failed";
  }
>;

Selectors:

function isEntityPending(registry: PendingRegistry, entityKey: string) {
  return Object.values(registry).some(
    (command) => command.entityKey === entityKey && command.status === "pending",
  );
}

This allows UI like:

only disable row being deleted
show row-level spinner
allow independent edits elsewhere
show bulk pending summary

14. Optimistic Workflow as State Machine

For high-complexity actions, use machine thinking.

Reducer:

type SaveState =
  | { tag: "idle" }
  | { tag: "optimistic"; commandId: string; patch: Patch }
  | { tag: "pending"; commandId: string; patch: Patch }
  | { tag: "conflict"; commandId: string; serverVersion: number }
  | { tag: "failed"; commandId: string; error: MutationError };

type SaveEvent =
  | { type: "SUBMIT"; commandId: string; patch: Patch }
  | { type: "REQUEST_SENT"; commandId: string }
  | { type: "SUCCESS"; commandId: string }
  | { type: "FAILURE"; commandId: string; error: MutationError }
  | { type: "DISCARD" };

Guard stale responses:

function saveReducer(state: SaveState, event: SaveEvent): SaveState {
  switch (event.type) {
    case "SUCCESS":
      if (!("commandId" in state) || state.commandId !== event.commandId) {
        return state;
      }

      return { tag: "idle" };

    case "FAILURE":
      if (!("commandId" in state) || state.commandId !== event.commandId) {
        return state;
      }

      if (event.error.type === "conflict") {
        return {
          tag: "conflict",
          commandId: event.commandId,
          serverVersion: event.error.serverVersion,
        };
      }

      return {
        tag: "failed",
        commandId: event.commandId,
        error: event.error,
      };

    default:
      return state;
  }
}

15. Undo as Compensation

Undo is not always rollback.

If server already accepted delete, undo is a new command.

DELETE item succeeded
Toast shows Undo for 5 seconds
User clicks Undo
Client sends RESTORE item command

Model it as compensation:

type UndoableCommand = {
  commandId: string;
  kind: "delete-invoice";
  entityId: string;
  undoUntil: number;
  compensation: "restore-invoice";
};

Do not pretend server state never changed if it did.

For audit-heavy domains, compensation must be explicit.


16. Offline and Retry

Offline optimistic workflows are a separate level of complexity.

You need:

persistent command queue
idempotency keys
replay ordering
conflict policy
user-visible pending state
retry/backoff
logout cleanup
schema migration

Do not accidentally build offline mode by persisting optimistic cache blindly.

If commands can survive page reload, they are no longer UI-only state.

They are durable client-side workflow state.


17. Security and Authorization

Optimistic UI must not imply authorization.

Client can show expected result, but server enforces permission.

Bad:

Hide approval button unless client says allowed.
Optimistically mark case approved.
Never handle permission failure because UI “checked already”.

Better:

Client permission controls affordance.
Server validates final command.
Permission failure has explicit state.
Audit action is based on server result, not optimistic UI.

For regulated systems, optimistic approval is usually the wrong default.

Use responsive pending UI instead.


18. Testing Strategy

Test optimistic workflows by time and interleaving.

18.1 Success path

user submits command
optimistic UI appears immediately
request sent once
server success reconciles canonical result
pending indicator clears

18.2 Failure rollback

base state A
optimistic patch B
server failure
state returns to A or shows failed item
error message visible
retry available if appropriate

18.3 Concurrent mutation

patch 1 applied
patch 2 applied
patch 1 fails
patch 2 succeeds
final projection preserves patch 2

18.4 Stale response guard

command A sent
command B sent
response A arrives after B
response A must not overwrite B

18.5 Conflict

server returns version conflict
UI exits optimistic state
conflict message appears
user can reload/retry/merge

19. Observability

Track optimistic workflow as command lifecycle.

optimistic.command_started
optimistic.patch_applied
optimistic.server_succeeded
optimistic.server_failed
optimistic.rolled_back
optimistic.conflict_detected
optimistic.retry_clicked
optimistic.compensation_started

Metadata:

type OptimisticTelemetry = {
  commandId: string;
  kind: string;
  entityKey?: string;
  durationMs?: number;
  failureType?: string;
  rollbackApplied?: boolean;
  compensation?: string;
};

Never log sensitive form payloads unless explicitly approved by data governance.


20. Failure Modes

20.1 Optimistic state treated as truth

Symptom:

UI shows approved state before server confirms.
Another workflow starts from that fake approved state.

Fix:

Represent pending/optimistic explicitly.
Block dependent high-risk commands until reconciliation.

20.2 Rollback overwrites newer intent

Symptom:

User performs two edits.
First fails.
Rollback restores snapshot before both edits.

Fix:

Use patch queue and command IDs.
Remove failed patch, reproject remaining patches.

20.3 Temp ID leak

Symptom:

client:123 appears in URL, analytics, or later API call.

Fix:

Use explicit ClientId/ServerId typing and reconcile mapping.

20.4 Silent rollback destroys trust

Symptom:

User posts comment.
Comment appears then disappears.
No explanation.

Fix:

Keep failed optimistic item with retry or visible error.

20.5 Duplicate command

Symptom:

Double click creates two records.

Fix:

Disable command while pending.
Use idempotency key when possible.
Server deduplicates command.

20.6 Invalidation gap

Symptom:

Detail cache updated but list badge/count stale.

Fix:

Mutation impact map.
Invalidate related aggregate queries.
Prefer server canonical response for totals.

20.7 Optimistic high-risk domain action

Symptom:

UI says regulatory case is closed before server audit transition succeeds.

Fix:

Do not optimistically commit high-risk lifecycle state.
Show pending command state instead.

21. Decision Checklist

Before implementing optimistic workflow, answer:

What is the authoritative server state?
What exact optimistic patch is applied?
Is the action reversible?
What is the rollback context?
Could concurrent mutation happen?
What is the command ID?
Do we need idempotency key?
Does this create temp IDs?
How are temp IDs reconciled?
What server fields are canonical?
What failures are retryable?
What failures are validation/conflict/permission?
Should failed optimistic UI stay visible?
Which queries must be invalidated?
Can dependent commands run while optimistic state is pending?
What telemetry is needed?

22. Summary

Optimistic UI is not a styling trick.

It is state orchestration under uncertainty.

Production optimistic workflow requires:

clear server authority
explicit optimistic projection
command identity
rollback or compensation strategy
temp ID reconciliation
conflict handling
invalidation map
pending registry
testing for interleavings
observability

The safe rule:

Optimistic UI is good when failure is rare, reversible, understandable, and low-risk.
Use pending UI instead when authority, auditability, money, permission, or irreversible lifecycle transition matters more than speed illusion.

Next, we close Module 9 by cataloging workflow failure modes.

Lesson Recap

You just completed lesson 94 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.