--- title: "Demo client-state (UI-prefs zonder DB)" status: active audience: [ai-agent, contributor] language: nl last_updated: 2026-05-12 when_to_read: "Bij elk nieuw UI-element dat de demo-gebruiker zou willen kunnen wijzigen — filter, sortering, panel-state, geselecteerde scope (product/sprint), enz." --- # Patroon: Demo client-state De demo-gebruiker (`session.isDemo === true`) deelt één DB-rij met alle andere demo-bezoekers. DB-writes voor demo zouden cross-bezoeker-pollution geven, dus de three-layer policy uit [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) blokkeert ze. PBI-80 introduceert één uitzondering: **client-side UI-state mag gewijzigd worden, in-memory en zonder server-call.** --- ## Wanneer toepassen | Soort wijziging | Voor demo? | Hoe | |---|---|---| | Filter / sortering / collapse / split-pane / selectie | **Ja** | `useUserSettingsStore.setPref([...], value)` — store regelt de demo-fork al | | Wisselen van actief product of sprint | **Ja** | `router.push('/products/...')` zonder server-action | | PBI/story/taak/sprint create/update/delete/reorder | **Nee** | Server-action met 403-guard blijft hard verplicht | | Account, rollen, pairing, web-push | **Nee** | Idem | --- ## Hoe `isDemo` lezen (client component) ```tsx import { useUserSettingsStore } from '@/stores/user-settings/store' const isDemo = useUserSettingsStore(s => s.context.isDemo) ``` `UserSettingsBridge` hydrateert deze waarde in `app/(app)/layout.tsx`, dus elke client child ziet meteen de juiste vlag. --- ## Voorbeeld 1 — UI pref (filters, sort, layout) Geen extra werk. De store-actie regelt de demo-fork zelf: ```tsx // Werkt voor alle gebruikers, demo + niet-demo useUserSettingsStore.getState().setPref( ['views', 'pbiList', 'filterStatus'], 'OPEN', ) ``` Voor demo doet `setPref` een lokale Zustand-merge zonder server-call; voor niet-demo gaat het via `updateUserSettingsAction` (DB + SSE). --- ## Voorbeeld 2 — Scope-wissel (product/sprint) Fork in de UI-handler — server-action blijft achter de fork onveranderd: ```tsx function handleSwitchProduct(productId: string) { if (productId === activeId) return if (isDemo) { router.push(`/products/${productId}`) return } startTransition(async () => { const result = await setActiveProductAction(productId) // ... bestaande not-demo flow }) } ``` Voor pagina's waarvan de scope al in de URL zit (zoals `/products/[id]/sprint/[sprintId]`) is `router.push` met de gewenste path voldoende — server resolveert de juiste data uit de URL-params. --- ## Visuele consistentie na URL-only switch Server-rendered layouts blijven voor demo de seed-default lezen (`user.active_product_id`, `user.settings.layout.activeSprints[...]`). Als de UI een "actief X"-label toont dat van de server-prop komt, leid het voor demo af uit `pathname`: ```tsx const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null const displayActive = isDemo && urlProductId ? products.find(p => p.id === urlProductId) ?? activeProduct : activeProduct ``` Gebruik `displayActive` in de render in plaats van de prop. --- ## Verboden voor demo - Server-action aanroepen zonder fork — 403 + onnodige toast. - Wegschrijven naar cookies of localStorage — pollutie tussen bezoekers. - `setActiveSprintInSettings` / vergelijkbare DB-helpers rechtstreeks aanroepen. - Web-push subscription registreren — schrijft naar gedeelde `PushSubscription`-tabel. --- ## Defense in depth Server-actions (`actions/active-product.ts`, `actions/active-sprint.ts`, `actions/user-settings.ts`) **behouden** hun `if (session.isDemo) return 403`-guard. Als toekomstige UI-code per ongeluk de fork mist, faalt de call hard met 403 en zien we het via toast/logs. --- ## Zie ook - [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) — three-layer beschermingen + de PBI-80-uitzondering. - [docs/patterns/proxy.md](./proxy.md) — proxy-laag die `/api/*`-writes voor demo afvangt. - [stores/user-settings/store.ts](../../stores/user-settings/store.ts) — bron van waarheid voor `isDemo` + `setPref` met demo-fork.