Universal frontend data-layer patterns — server state library architecture (e.g.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add Canhada-Labs/ceo-orchestration --skill frontend-data-layer --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Frontend Data Layer?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/canhada-labs-frontend-data-layer-ceo-orchestration)More formats (shields.io, HTML) on the badges page.
---
name: Frontend Data Layer & Real-Time (Universal)
description: Universal frontend data-layer patterns — server state library architecture (e.g.
TanStack Query), client state store architecture (e.g. Zustand/Redux), WebSocket/SSE
subscription lifecycle, cache strategy, query key conventions, mutation/invalidation
patterns, optimistic updates, error retry/backoff, rAF batching for high-frequency
streams, staleness UX, and state-overlap prevention for the {{PROJECT_NAME}} frontend.
Use when working with queries, mutations, stores, WebSocket, SSE, data fetching, caching,
or any real-time data display. Also use when the user mentions "queries", "cache",
"stale", "refetch", "WebSocket", "SSE", "Zustand", "store", "subscription", "real-time",
or when reviewing data flow architecture. Domain-specific data concerns (financial
display, order-book throttling, etc.) live under
`domains/<domain>/skills/frontend-data-layer/SKILL.md`.
trigger: Any work involving data fetching, state management, real-time data, or API integration.
# --- smart-loading fields (PLAN-083 Wave 0b sub-agent 0.7b) ---
domain: frontend
priority: 4
risk_class: medium
stack: [typescript, react]
context_budget_tokens: 1200
inactive_but_retained: false
repo_profile_binding:
frontend: {active: true, priority: 3}
engine: {active: true, priority: 5}
fintech: {active: true, priority: 5}
trading-readonly: {active: true, priority: 6}
generic: {active: true, priority: 5}
activation_triggers:
- {event: file-edit, glob: "**/queries/**"}
- {event: file-edit, glob: "**/stores/**"}
- {event: help-me-invoked, regex: "(?i)tanstack|zustand|redux|websocket|sse|cache|stale|refetch"}
---
# Frontend Data Layer & Real-Time — Universal
This skill captures project-agnostic data-layer patterns. Anything specific to
a business domain (financial display rules, order-book throttling, price
caching, etc.) is extracted to
`domains/<domain>/skills/frontend-data-layer/SKILL.md` and composed on
top of this baseline.
## Owners
- **Data Layer Lead** (archetype) — Query architecture, cache, stores.
- **Real-Time Engineer** (archetype) — WS, SSE, rAF batching, staleness.
> Replace with your concrete personas when adopting this skill. See
> `.claude/frontend-team.md` for the archetype → persona mapping.
## Architecture
### Data Flow
```
REST API → fetch wrapper → server-state library (queries*.ts) → Components
WS → ws.ts singleton → ws-store (client store + rAF) → Components
SSE → useSSE() hook → Component local state → Components
Auth/User → auth client (RLS / session) → Auth store
```
### QueryClient Config (example — TanStack Query)
- staleTime: 30s (prevents refetch on page transitions)
- gcTime: 1h (enables IndexedDB restoration)
- retry: 1 (jittered exponential backoff, cap 5s)
- refetchOnWindowFocus: false
- Persistence: IndexedDB (maxAge 1h, excludes sensitive keys like admin/user/billing)
## Query Layer Organization
### Centralized queries.ts
All query definitions live in `src/<feature>/queries*.ts`. Components consume
them via typed hooks; they NEVER inline `useQuery` with a literal URL or key.
```typescript
// example: feature/queries.ts
export const featureQueries = {
list: (filter: Filter) =>
queryOptions({
queryKey: ['feature', 'list', filter],
queryFn: () => api.listFeatures(filter),
staleTime: 30_000,
}),
detail: (id: string) =>
queryOptions({
queryKey: ['feature', 'detail', id],
queryFn: () => api.getFeature(id),
staleTime: 60_000,
}),
};
// Component usage
const { data } = useQuery(featureQueries.detail(id));
```
### Query Key Conventions
#### Namespace Pattern
```typescript
// GOOD: Namespaced, all params in key
['feature', 'list', filter]
['feature', 'detail', id]
['admin', 'health']
// BAD: No namespace, missing params
['list'] // Missing namespace + filter
['detail', id] // Missing namespace
```
### Rules
1. **ALL queries in `src/<feature>/queries*.ts`** — NEVER inline `useQuery` in
components.
2. Use `queryOptions()` factories (NOT `useQuery()` wrappers).
3. **Include ALL parameters in the key** — missing params = cache collisions.
4. **Mutations MUST invalidate affected query keys.** Every mutation has a
matching `onSuccess` that calls `queryClient.invalidateQueries({ queryKey })`.
5. **staleTime varies by data type** — tune it per query, don't globalize.
## Server State vs Client State Boundaries
| Type | Examples | Tool |
|------|----------|------|
| Server state | Any data fetched from an API, cached and synchronized | TanStack Query, SWR, RTK Query |
| Client state | UI mode, draft forms, theme, open/closed panels | Zustand, Redux, Jotai, useState |
**Rule:** Data should live in ONE place. If it comes from the server, use your
server-state library. If it's client-only UI state, use a store. NEVER both.
### State Overlap Smells
Watch for these anti-patterns — they're how data gets two sources of truth:
1. **Same data fetched by a store `setInterval` AND a server-state query.**
Pick one. Kill the other.
2. **Client-side simulated data + server-side history of the same thing** with
no clear "which is truth" rule. Write the rule down, enforce it in code.
3. **A store that fetches directly from the backend**, bypassing your
server-state library. That store is now a second, invisible cache.
## Client Stores
### Store Taxonomy (example — Zustand)
| Store | Purpose | Persisted? |
|-------|---------|-----------|
| theme-store | Dark/light mode | localStorage |
| ui-mode-store | Advanced/simple mode | localStorage |
| notification-store | Alerts/notifications | localStorage (unreadCount) |
| draft-store | In-progress form drafts | NO (ephemeral) |
| workspace-store | Layout / panel arrangement | localStorage |
### Rules
1. **Stores hold client state only.** Never use a store as a secondary cache
for server data.
2. **Persist deliberately.** Persist preferences and UI state. NEVER persist
authenticated tier, role, permissions, or anything security-sensitive — it's
spoofable.
3. **Expose typed selector hooks.** Components should not import the raw
`useStore` hook; they should import `useTheme()`, `useDensity()`, etc.
4. **Use shallow comparison for array/object selectors** (e.g. Zustand's
`useShallow`, Redux's `createSelector`) to prevent unnecessary re-renders.
## Cache Invalidation Patterns
### Rules
1. **Every mutation MUST invalidate the queries it affects.** If you edit a
resource, invalidate its list query and its detail query.
2. **Invalidate by key prefix**, not by exact key, when a mutation affects an
unknown subset: `invalidateQueries({ queryKey: ['feature'] })`.
3. **Use `setQueryData` for targeted updates** when you already have the new
server value in the mutation response — skips a refetch.
4. **Never rely on `refetchOnWindowFocus` to sync state.** It's a safety net,
not a strategy.
## Optimistic Updates
Use optimistic updates for mutations where the user needs instant feedback and
the failure mode is recoverable.
```typescript
const mutation = useMutation({
mutationFn: updateItem,
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ['item', next.id] });
const previous = queryClient.getQueryData(['item', next.id]);
queryClient.setQueryData(['item', next.id], next);
return { previous };
},
onError: (_err, next, ctx) => {
// Roll back
if (ctx?.previous) {
queryClient.setQueryData(['item', next.id], ctx.previous);
}
},
onSettled: (_data, _err, next) => {
queryClient.invalidateQueries({ queryKey: ['item', next.id] });
},
});
```
### Rules
1. **Cancel in-flight queries before mutating the cache** so a stale fetch
can't clobber your optimistic value.
2. **Always snapshot the previous value** for rollback.
3. **Always `invalidateQueries` on settle** so the final state matches the
server.
4. **Do NOT use optimistic updates for operations that cannot be safely rolled
back** (payments, irreversible submissions). Show a pending state instead.
## Error Retry and Backoff
### Default Policy
- **retry: 1** — one automatic retry, then surface the error.
- **Jittered exponential backoff**, capped (e.g. 5s). Don't hammer a struggling
backend.
- **Do NOT retry 4xx errors** — they won't change on retry. Retry only 5xx and
network errors.
### Error Surfacing
1. **Show the error in the same surface the data would have rendered in.**
Don't toast a silent query failure — the user will stare at a blank chart.
2. **Provide a retry button** so users aren't stuck waiting for the automatic
retry.
3. **Log errors to your monitoring system** with the query key and params so
they're searchable in Sentry/Datadog.
## WebSocket Architecture (Generic Transport)
### Connection Lifecycle
- **Singleton connection:** `export const ws = new AppWebSocket()`. Never
construct `new WebSocket()` per component.
- **Lazy connect:** The WS opens on the first subscribe/setChannels call.
- **Ready protocol:** Wait for a server `ready` message before reporting
connected. That gives the server time to load subscriptions.
- **Reconnect:** Exponential backoff (1s → 30s) + jitter (±25%). Honour
backoff codes (e.g. rate-limit code 4008 = 10s minimum).
- **Heartbeat:** Ping every 25s, staleness check every 30s. Force reconnect
after 90s of no message.
### WS Store (rAF Batching Pattern)
High-frequency streams can fire faster than the browser's refresh rate. Coalesce
updates inside `requestAnimationFrame`:
- **rAF batching:** Cap N messages per frame (e.g. 30). Carry excess to the
next frame.
- **Per-key throttle:** Optional millisecond cap per message key to prevent one
noisy topic from starving the others.
- **Delta protocol:** Apply deltas to the pending (not-yet-flushed) frame so
overlapping deltas collapse before React sees them.
- **23 typed selector hooks** isolate components from the raw store. Components
NEVER import `useWSStore` directly — they import `useChannelState(id)` etc.,
which uses `useShallow` for array/object selectors.
### Subscription Hooks
Wrap subscription management in tiny hooks with explicit cleanup:
- `useWSChannels(channels)` — subscribes on mount, sets channels to `[]` on
unmount.
- Read-only listener hooks don't subscribe themselves; they just read state and
need no cleanup.
- **Every hook MUST clean up on unmount.** A subscription leak is usually
invisible until a user opens/closes 50 panels and the WS is throttled.
## SSE Architecture (Generic Transport)
- **Generic factory:** `useSSE(endpoint)` with reconnect, circuit breaker, and
cleanup in one place. Per-consumer hooks (`useSSEAlerts`,
`useSSENotifications`, etc.) sit on top of the factory.
- **Let the browser reconnect.** Do not wrap `EventSource` in a manual
reconnect loop — it already retries. Track the reconnect count for alerting
instead.
- **Close on unmount.** `source.close()` in the effect cleanup is mandatory.
- **Cancel any manual reconnect/backoff timers on unmount** — dangling timers
are the most common SSE leak.
## Staleness UX
When real-time transports drop, the UI MUST communicate it — silent stale data
is worse than no data.
- **ConnectionStatus dot** — Green / Yellow / Red with hysteresis (e.g. 3s
grace) so it doesn't flicker.
- **Offline banner** — Full-width warning when WS is disconnected for more
than a few seconds.
- **Per-feature staleness tiers** — Fresh (<15s), Stale (15-60s),
Degraded (60-120s), Offline (>120s). Label which tier each data feed is in.
- **REST fallback polling** — When the WS is down, critical feeds fall back to
REST polling at a low rate (e.g. every 10s).
## Generic Data-Fetching Rules
1. **Queries are declarative.** The query key is the identity of the data. Two
identical keys MUST return the same data.
2. **Mutations are verbs.** Every mutation is named for the action it performs
and has a matching invalidation.
3. **Loading states MUST be skeletons**, not spinners, for structured data.
Skeletons preserve layout.
4. **Error states MUST be recoverable.** Always give the user a way forward.
5. **Real-time and query data MUST NOT both feed the same component from
independent paths.** Pick one source of truth per feature.
6. **Persist only what you can safely rehydrate.** If the persisted data could
be wrong on next load (tier, permissions, session), don't persist it.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!