Cross-directory selectors subscribe to the narrow child-store field they aggregate. Session aggregation listens to `state.session`; per-session status listens only to that session's `state.session_status` entry. Unrelated streaming events such as `message.part.delta` must not trigger global session/status scans.
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
```typescript
// WRONG — clones everything, breaks referential equality for all subscribers
constdraft={
...current,
session:[...current.session],
message:{...current.message},
part:{...current.part},
permission:{...current.permission},
// ...
}
// RIGHT — only clone what this event type touches
constdraft={...current}
switch(event.type){
case"message.part.delta":
draft.part={...current.part}
break
}
```
## Why this matters
Zustand skips re-renders when a selector returns the same reference (`Object.is`). If you spread `session: [...current.session]` but the event only modifies `part`, the `session` array gets a new reference. Every component using `useSessions()` re-renders for nothing.
During streaming, `message.part.delta` fires ~60 times/sec. Eagerly cloning all fields caused every subscriber in the entire app to re-render 60/sec — a 10x overhead. Targeted cloning reduced MessageList renders from ~1972 to ~296 per session.
## Event → field mapping
Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`:
1. Add the case to the event reducer (`event-reducer.ts`)
2. Add a corresponding case to the switch in `handleDirectoryEvent` (`sync-context.tsx`) that clones **only** the fields your reducer writes to
3. If your event fires frequently (more than a few times per second), verify that unrelated components don't re-render — check with the stream perf counters
## Selector hygiene
Select leaf values, not containers:
```typescript
// WRONG — returns entire Map/object, new reference on any mutation
useDirectorySync((s)=>s.permission)
// RIGHT — returns the value for one key, stable unless that key changes
Same applies to `useStreamingStore` — select `.get(key)` not the Map itself.
## Store splitting pattern
### Why split
A single Zustand store with N properties means every subscriber's selector re-evaluates on every state change — even if the change is unrelated to what that subscriber reads. During streaming, `sessionMemoryState` updates ~60/sec. Before the split, all 68+ `useSessionUIStore` subscribers re-evaluated on each update. After splitting into focused stores, only `useViewportStore` subscribers (2-3 components) re-evaluate.
The optimization multiplies with targeted event cloning: fewer new references per event × fewer subscribers per store = dramatically less work per SSE frame.
1.**Never add to `session-ui-store`** unless it's session selection, draft lifecycle, or abort state
2.**Group by change frequency** — state that changes during streaming (viewport, memory) must not live with state that changes on user action (selections, input)
3.**Group by subscriber set** — if only 2 components read a value, it should be in a store that only those 2 components subscribe to
4.**Prefer a new store over growing an existing one** if the new state has different subscribers or change frequency
5.**Cross-store reads use `.getState()`** — actions in one store that need to read another store call `useOtherStore.getState()` (imperative, no subscription)
### Anti-patterns
```typescript
// WRONG — stuffing unrelated state into one store
constuseEverythingStore=create(()=>({
voiceMode:"idle",
scrollAnchor: 0,
selectedModel: null,
pendingInput:"",
// 20 more fields...
}))
// RIGHT — separate stores by concern + change frequency