---
title: "Zustand optimistische update + rollback"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-10
when_to_read: "When adding client-side state mutations that need optimistic UI and rollback (DnD, status toggles)."
---

# Patroon: Zustand optimistische update + rollback

Sinds PBI-74 lopen optimistic mutations via `applyOptimisticMutation`/
`rollbackMutation`/`settleMutation` op de **workspace-store**. Het bredere
patroon (store-design, SSE-integratie, restore-hints, tests) staat in
[workspace-store.md](./workspace-store.md). Dit document beschrijft het
DnD/status-mutation flow specifiek.

## Patroon

1. Snapshot rollback-info via `applyOptimisticMutation` — krijgt `mutationId`.
2. Pas state direct aan via `setState`.
3. Server-actie aanroepen.
4. Op success: `settleMutation(mutationId)` (ruimt pending-record op).
5. Op error: `rollbackMutation(mutationId)` (herstelt vorige state + toast).

Cross-priority drag vereist twee mutaties (order + entity-patch) die samen
settlen of rollbacken.

## Voorbeeld — PBI reorder

```tsx
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'

function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event
  if (!over || active.id === over.id) return

  const store = useProductWorkspaceStore.getState()
  const prevOrder = [...store.relations.pbiIds]
  const oldIndex = prevOrder.indexOf(active.id as string)
  const newIndex = prevOrder.indexOf(over.id as string)
  if (oldIndex === -1 || newIndex === -1) return
  const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)

  // 1. Snapshot rollback-info
  const mutationId = store.applyOptimisticMutation({
    kind: 'pbi-order',
    prevPbiIds: prevOrder,
  })

  // 2. Optimistisch toepassen
  useProductWorkspaceStore.setState((s) => {
    s.relations.pbiIds = newOrder
  })

  // 3-5. Server bevestigt of niet
  startTransition(async () => {
    const result = await reorderPbisAction(productId, newOrder)
    const st = useProductWorkspaceStore.getState()
    if (result.success) {
      st.settleMutation(mutationId)
    } else {
      st.rollbackMutation(mutationId)
      toast.error('Volgorde opslaan mislukt')
    }
  })
}
```

## Voorbeeld — entity-patch (priority-wijziging)

```tsx
const prevPbi = store.entities.pbisById[id]
const patchMutationId = store.applyOptimisticMutation({
  kind: 'entity-patch',
  entity: 'pbi',
  id,
  prev: prevPbi,
})
useProductWorkspaceStore.setState((s) => {
  const pbi = s.entities.pbisById[id]
  if (pbi) pbi.priority = newPriority
})
// settle/rollback identiek aan order-flow
```

## Mutation-soorten

| `kind` | Rollback-data | Use-case |
|---|---|---|
| `pbi-order` | `prevPbiIds` | DnD reorder van PBI's |
| `story-order` | `pbiId` + `prevStoryIds` | DnD reorder van stories binnen een PBI |
| `task-order` | `storyId` + `prevTaskIds` | DnD reorder van tasks binnen een story |
| `entity-patch` | `entity` + `id` + `prev` (volledig vorig record of `undefined` voor delete-rollback) | Property-wijzigingen (priority, status), of optimistic delete/undelete |

## SSE-echo idempotent verwerken

Wanneer de server bevestigt en de NOTIFY-trigger het bijbehorende event
emitteert, mag `applyRealtimeEvent` **geen dubbele insert** veroorzaken en
**geen rollback triggeren**. INSERTs checken bestaan; UPDATEs mergen
into-existing. Zie `applyRealtimeEvent` in [`stores/product-workspace/store.ts`](../../stores/product-workspace/store.ts).
