--- 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).