# State Management — Property Match ## Decision Tree ``` Is it server data (fetched from an API or provider)? → React Query (useQuery / useMutation) Is it global UI state shared across unrelated components? → Zustand store Is it local to a single component or parent-child chain? → useState / useReducer (local state) Can it be computed from existing state/data? → Derived state (compute inline — no separate store field) Is it cross-cutting auth / session context accessed in non-React code? → Zustand store read via getState() (not useStore hook) ``` --- ## React Query — Server State **Rule:** React Query owns all data that comes from a provider (mock or real). Never copy React Query data into a Zustand store. ### When to use - Fetching lists or detail records (`useQuery`) - Creating, updating, deleting records (`useMutation`) - Anything that needs cache invalidation or background refetch ### Patterns ```ts // ✅ Correct — server data in React Query const { data: properties } = useProperties() // ✅ Correct — mutation with cache invalidation const createProp = useCreateProperty() createProp.mutate(input, { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['properties'] }) }) // ❌ Wrong — copying server data into a Zustand store const [properties, setProperties] = useState([]) useEffect(() => { fetchProperties().then(setProperties) }, []) ``` ### staleTime constants All stale times live in `src/lib/constants.ts` — never define them locally in hooks. | Constant | Value | Used for | |----------|-------|---------| | `STALE_PROPERTIES` | 5 min | Property lists and details | | `STALE_MATCHES` | 2 min | Match results (change with need edits) | | `STALE_SIGNALS` | 5 min | Future availability signals | | `STALE_MARKET_SIGNALS` | 30 s | Market intelligence (ops team, real-time) | | `STALE_REVIEW_QUEUE` | 30 s | Review queue tasks (ops team, real-time) | ### Query key conventions ```ts ['entity'] // list: ['properties'], ['matches'], ['reminders'] ['entity', id] // single: ['property', id], ['match', id] ['entity', 'scope', id] // scoped: ['matches', 'need', needId] ['entity', filters] // filtered: ['properties', { assetType: 'OFFICE' }] ``` --- ## Zustand — UI / Interaction State **Rule:** Zustand owns UI state only. It must never hold data that belongs in React Query. ### When to use - Multi-step wizard state (`offerWizardStore`) - Dialog open/close + pending item (`shortlistStore`, `pipelineStore`) - Sidebar / layout flags (`layoutStore`) - Auth session (`sessionStore` — special case, also read by services via `getState()`) - Toast queue (`toastStore`) - Active selection in a panel (`matchCenterStore`, `reminderStore`) ### When NOT to use - Data fetched from a provider → use React Query - State used only inside one component → use `useState` - Values derived from existing state → compute inline ### Selector rules Always use a selector. Subscribing to the full store causes re-renders on every state mutation. ```ts // ✅ Individual field selector — re-renders only when that field changes const isOpen = useLayoutStore(s => s.isRightPanelOpen) const close = useLayoutStore(s => s.closeRightPanel) // ✅ useShallow for multiple fields from the same store import { useShallow } from 'zustand/react/shallow' const { filterType, filterStatus } = useReminderStore( useShallow(s => ({ filterType: s.filterType, filterStatus: s.filterStatus })) ) // ❌ No selector — subscribes to all store fields, re-renders on any change const { isOpen, close } = useLayoutStore() // ✅ Non-React code (services, mutations) reads store via getState — no subscription const user = useSessionStore.getState().currentUser ``` ### Store inventory | Store | Responsibility | Key state | |-------|---------------|-----------| | `layoutStore` | App shell layout | `activeWorkspace`, `sidebarCollapsed`, `isRightPanelOpen` | | `sessionStore` | Auth / current user | `currentUser`, `isAuthenticated`, `sessionStatus` | | `assistantStore` | AI drawer conversation | `isOpen`, `messages`, `context`, `isLoading` | | `compareStore` | Compare tray items | `compareItems[]` | | `pipelineStore` | Pipeline items + add-dialog | `items[]`, `dialogOpen`, `pendingItem` | | `shortlistStore` | Shortlist selection + add-dialog | `selectedShortlistId`, `dialogOpen`, `pendingItem` | | `offerWizardStore` | Multi-step offer wizard | `isOpen`, `currentStep`, `selectedPropertyIds`, `editableFields` | | `matchCenterStore` | Supply-side match center selection | `selectedPropertyId`, `selectedNeedId` | | `reminderStore` | Reminder list filters + drawer | `filterType`, `filterStatus`, `selectedId`, `drawerOpen` | | `toastStore` | Toast notification queue | `toasts[]` | ### Adding a new store field Ask these questions first: 1. Is this server data? → React Query instead. 2. Is this only used in one component? → `useState` instead. 3. Is this derived from existing state? → compute it, don't store it. --- ## Local State — Component-Scoped **Rule:** Default to `useState`. Only escalate to Zustand when state genuinely needs to be shared across unrelated components. ### When to use - Form input values - Toggle / accordion open state - Hover / focus effects - Step progress inside a self-contained wizard step - Any state that resets when the component unmounts ```ts // ✅ Local — form input, no other component needs this const [name, setName] = useState('') // ✅ Local — dialog only opened from one place const [open, setOpen] = useState(false) // ❌ Should be local — extracted to store unnecessarily // (e.g. a "confirmDialogOpen" only ever toggled from one parent) ``` --- ## Derived State — Compute, Don't Store **Rule:** Never store a value that can be computed from existing state or query data. Compute it at render time. ```ts // ✅ Derived — compute from store const isFull = compareItems.length >= MAX_COMPARE_ITEMS // NOT stored // ✅ Derived — compute from React Query data const overdueReminders = reminders.filter(r => isPastDue(r.dueDate)) // NOT stored // ❌ Stored derived state — causes sync bugs const [overdueCount, setOverdueCount] = useState(0) useEffect(() => setOverdueCount(reminders.filter(...).length), [reminders]) ``` Exception: expensive computations (e.g. score calculation over thousands of items) may use `useMemo`. --- ## Context — When Neither React Query nor Zustand Fits Use React Context for: - Dependency injection (swap provider implementations) - Tree-scoped state (e.g. a form context for nested inputs) - Auth abstraction (`AuthProvider` wraps `sessionStore` so components don't import the store directly) Do NOT use Context as a replacement for React Query or Zustand — it causes cascading re-renders without cache or subscription granularity. --- ## Service / Non-React Code Services must not import React hooks. They access Zustand state via `getState()`: ```ts // ✅ In a service — no hook, no subscription const user = useSessionStore.getState().currentUser // ✅ In a React Query mutation onError onError: () => useToastStore.getState().showToast('Fehler aufgetreten', 'error') // ❌ Services must never call useStore hooks import { useSessionStore } from '../stores/sessionStore' const { currentUser } = useSessionStore() // only valid inside a React component ``` --- ## Anti-Patterns to Avoid | Anti-pattern | Why bad | Fix | |---|---|---| | `useStore()` without selector | Re-renders on every store mutation | Use `s => s.field` selector or `useShallow` | | Server data in Zustand | Duplicates cache, causes stale/sync bugs | React Query | | Derived state stored in state | Sync bugs, extra renders | Compute inline | | Local dialog state in global store | Bloats store, breaks encapsulation | `useState` | | Cross-store imports | Tight coupling, circular risk | Keep stores independent | | Hook-local `STALE_*` constants | Inconsistent cache behaviour | Use `src/lib/constants.ts` |