- layoutStore: remove 4 dead field groups (pinnedPanels, selectedResultId, compareTrayVisible, notificationsOpen) — none were read outside the store - CompareTray: drop dead useLayoutStore side-effect (state was write-only) - 8 components: replace bare useStore() with explicit selectors / useShallow to prevent unnecessary re-renders on unrelated state mutations - lib/constants: add STALE_MARKET_SIGNALS + STALE_REVIEW_QUEUE (30s each) - useMarketSignals, useReviewQueue: use global constants instead of hook-local magic numbers - Add STATE_MANAGEMENT.md: decision tree + rules for RQ/Zustand/local/derived Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.9 KiB
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
// ✅ 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
['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 viagetState()) - 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.
// ✅ 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:
- Is this server data? → React Query instead.
- Is this only used in one component? →
useStateinstead. - 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
// ✅ 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.
// ✅ 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 (
AuthProviderwrapssessionStoreso 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():
// ✅ 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 |