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>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
# 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` |
|
||||
Reference in New Issue
Block a user