Files
property-match/STATE_MANAGEMENT.md
Benjamin Sutter 5d53f35dea refactor(state): clean layoutStore, add selectors, centralise STALE constants
- 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>
2026-05-24 01:30:44 +02:00

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

// ✅ 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
// ✅ 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 (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():

// ✅ 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