data-fetching-architecture · diff
git:20260907.a8ef352 to git:20260907.25bf231
94 added, 622 removed. Audit A to A.
---
name: data-fetching-architecture
- description: 'LobeHub data-fetching pipeline guide. Use for service layer, Zustand store, SWR, lambdaClient, useClientDataSWR, useFetchXxx hooks, home first-paint flicker, persistent display caches, or migrating useEffect fetches.'
+ description: 'Use for client APIs, SWR hooks, cache invalidation, async errors, useEffect migration, home first paint and persistent caches.'
user-invocable: false
---
# LobeHub Data Fetching Architecture
- > **Related:** `store-data-structures` covers List vs Detail data shape rationale (Map vs Array).
-
- ## Architecture Overview
-
```text
- ┌─────────────┐
- │ Component │
- └──────┬──────┘
- │ 1. Call useFetchXxx hook from store
- ↓
- ┌──────────────────┐
- │ Zustand Store │
- │ (State + Hook) │
- └──────┬───────────┘
- │ 2. useClientDataSWR calls service
- ↓
- ┌──────────────────┐
- │ Service Layer │
- │ (xxxService) │
- └──────┬───────────┘
- │ 3. Call lambdaClient
- ↓
- ┌──────────────────┐
- │ lambdaClient │
- │ (TRPC Client) │
- └──────────────────┘
+ Component → Store useFetchXxx hook → Service → lambdaClient
+ ← SWR request state + store data ← response
```
- ## Core Principles
-
- ### ✅ DO
-
- 1. **Use Service Layer** for all API calls
- 2. **Use Store SWR Hooks** for data fetching (not useEffect)
- 3. **Use proper data structures** — see `store-data-structures` skill for List vs Detail patterns
- 4. **Use lambdaClient.mutate** for write operations (create/update/delete)
- 5. **Use lambdaClient.query** only inside service methods
- 6. **Naming convention** — read hooks are `useFetchXxx`, cache invalidation helpers are `refreshXxx` (e.g. `useFetchBenchmarks` / `refreshBenchmarks`). Mutations then chain `refreshXxx()` after the service call.
-
- ### ❌ DON'T
-
- 1. **Never use useEffect** for data fetching
- 2. **Never call lambdaClient** directly in components or stores
- 3. **Never use useState** for server data
- 4. **Never mix data structure patterns** — follow `store-data-structures` skill
+ ## Layer boundaries
- ---
+ - Services in `src/services/` own API calls: `.query()` for reads, `.mutate()` for writes.
+ Export a service instance per domain; components and stores do not call `lambdaClient` directly.
+ - Store read hooks use `useClientDataSWR` and return its SWR response, including `error`
+ and `mutate`. Sync successful results into the store through the wrapper's supported
+ callback (`onSuccess`, or `onData` for the sync wrapper).
+ - Components call these hooks and read store data through selectors. Do not fetch in
+ `useEffect` or duplicate server data in component `useState`.
+ - Use `useFetchXxx` for read hooks and `refreshXxx` for cache invalidation.
+ - For list/detail types, maps, reducers, and shared type sources, use
+ [Zustand data structures](../zustand/references/data-structures.md). For action classes,
+ internal actions, and `flattenActions`, use [zustand](../zustand/SKILL.md).
## Home First Paint and Persistent Caches
When changing home/sidebar first paint or persisted display data, read
[`references/home-first-paint.md`](./references/home-first-paint.md). It covers
avoiding flicker, reusing persistence, and the user `displaySnapshot.ts` boundary.
- ## Layer 1: Service Layer
-
- ### Purpose
-
- - Encapsulate all API calls to lambdaClient
- - Provide clean, typed interfaces
- - Single source of truth for API operations
-
- ### Service Structure
-
- ```typescript
- // src/services/agentEval.ts
- class AgentEvalService {
- // Query methods - READ operations
- async listBenchmarks() {
- return lambdaClient.agentEval.listBenchmarks.query();
- }
-
- async getBenchmark(id: string) {
- return lambdaClient.agentEval.getBenchmark.query({ id });
- }
-
- // Mutation methods - WRITE operations
- async createBenchmark(params: CreateBenchmarkParams) {
- return lambdaClient.agentEval.createBenchmark.mutate(params);
- }
-
- async updateBenchmark(params: UpdateBenchmarkParams) {
- return lambdaClient.agentEval.updateBenchmark.mutate(params);
- }
-
- async deleteBenchmark(id: string) {
- return lambdaClient.agentEval.deleteBenchmark.mutate({ id });
- }
- }
-
- export const agentEvalService = new AgentEvalService();
- ```
-
- ### Service Guidelines
-
- 1. **One service per domain** (e.g., agentEval, ragEval, aiAgent)
- 2. **Export singleton instance** (`export const xxxService = new XxxService()`)
- 3. **Method names match operations** (list, get, create, update, delete)
- 4. **Clear parameter types** (use interfaces for complex params)
-
- ---
-
- ## Layer 2: Store with SWR Hooks
-
- ### Purpose
-
- - Manage client-side state
- - Provide SWR hooks for data fetching
- - Handle cache invalidation
-
- ### State Structure
-
- ```typescript
- // src/store/eval/slices/benchmark/initialState.ts
- export interface BenchmarkSliceState {
- // List data - simple array
- benchmarkList: AgentEvalBenchmarkListItem[];
- benchmarkListInit: boolean;
-
- // Detail data - map for caching
- benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
- loadingBenchmarkDetailIds: string[];
+ ## Cache keys and refresh
- // Mutation states
- isCreatingBenchmark: boolean;
- isUpdatingBenchmark: boolean;
- isDeletingBenchmark: boolean;
- }
- ```
+ Reuse the domain key factory in `src/libs/swr/keys.ts`; define a shared key if the
+ domain has none. Include every parameter that changes the response: entity or parent
+ id, filters, sort, and pagination. Read and refresh must construct the same key.
- > For complete initialState, reducer, and internal dispatch patterns, see the `store-data-structures` skill.
+ Import `useClientDataSWR` and `mutate` from `@/libs/swr`, keeping the application's
+ workspace/cache handling. Return a `null` key when a required id is absent; passing
+ `undefined` to a store hook only disables fetching if that hook maps it to `null`.
- ### Actions
+ For example, inside an action class:
```typescript
- // src/store/eval/slices/benchmark/action.ts
- const FETCH_BENCHMARKS_KEY = 'FETCH_BENCHMARKS';
- const FETCH_BENCHMARK_DETAIL_KEY = 'FETCH_BENCHMARK_DETAIL';
-
- export interface BenchmarkAction {
- // SWR Hooks - for data fetching
- useFetchBenchmarks: () => SWRResponse;
- useFetchBenchmarkDetail: (id?: string) => SWRResponse;
-
- // Refresh methods - for cache invalidation
- refreshBenchmarks: () => Promise<void>;
- refreshBenchmarkDetail: (id: string) => Promise<void>;
-
- // Mutation actions
- createBenchmark: (params: CreateParams) => Promise<any>;
- updateBenchmark: (params: UpdateParams) => Promise<void>;
- deleteBenchmark: (id: string) => Promise<void>;
-
- // Internal methods - not for direct UI use
- internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
- internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
- }
-
- export const createBenchmarkSlice: StateCreator<EvalStore, any, [], BenchmarkAction> = (
- set,
- get,
- ) => ({
- // Fetch list — simple array stored in benchmarkList
- useFetchBenchmarks: () =>
- useClientDataSWR(FETCH_BENCHMARKS_KEY, () => agentEvalService.listBenchmarks(), {
- onSuccess: (data) => {
- set({ benchmarkList: data, benchmarkListInit: true }, false, 'useFetchBenchmarks/success');
- },
- }),
-
- // Fetch detail — null key disables the request when id is missing
- useFetchBenchmarkDetail: (id) =>
- useClientDataSWR(
- id ? [FETCH_BENCHMARK_DETAIL_KEY, id] : null,
- () => agentEvalService.getBenchmark(id!),
- {
- onSuccess: (data) => {
- get().internal_dispatchBenchmarkDetail({
- type: 'setBenchmarkDetail',
- id: id!,
- value: data,
- });
- get().internal_updateBenchmarkDetailLoading(id!, false);
- },
- },
- ),
-
- // Refresh methods
- refreshBenchmarks: () => mutate(FETCH_BENCHMARKS_KEY),
- refreshBenchmarkDetail: (id) => mutate([FETCH_BENCHMARK_DETAIL_KEY, id]),
-
- // CREATE — refresh list after creation
- createBenchmark: async (params) => {
- set({ isCreatingBenchmark: true }, false, 'createBenchmark/start');
- try {
- const result = await agentEvalService.createBenchmark(params);
- await get().refreshBenchmarks();
- return result;
- } finally {
- set({ isCreatingBenchmark: false }, false, 'createBenchmark/end');
- }
- },
-
- // UPDATE — optimistic update + refresh
- updateBenchmark: async (params) => {
- const { id } = params;
-
- // 1. Optimistic update
- get().internal_dispatchBenchmarkDetail({
- type: 'updateBenchmarkDetail',
- id,
- value: params,
- });
- // 2. Set loading
- get().internal_updateBenchmarkDetailLoading(id, true);
-
- try {
- // 3. Call service
- await agentEvalService.updateBenchmark(params);
- // 4. Refresh from server
- await get().refreshBenchmarks();
- await get().refreshBenchmarkDetail(id);
- } finally {
- get().internal_updateBenchmarkDetailLoading(id, false);
- }
- },
-
- // DELETE — optimistic update + refresh
- deleteBenchmark: async (id) => {
- get().internal_dispatchBenchmarkDetail({ type: 'deleteBenchmarkDetail', id });
- get().internal_updateBenchmarkDetailLoading(id, true);
-
- try {
- await agentEvalService.deleteBenchmark(id);
- await get().refreshBenchmarks();
- } finally {
- get().internal_updateBenchmarkDetailLoading(id, false);
- }
- },
-
- // Internal — dispatch to reducer (for detail map)
- internal_dispatchBenchmarkDetail: (payload) => {
- const currentMap = get().benchmarkDetailMap;
- const nextMap = benchmarkDetailReducer(currentMap, payload);
-
- // Skip set when nothing changed — avoids unnecessary re-renders
- if (isEqual(nextMap, currentMap)) return;
- set({ benchmarkDetailMap: nextMap }, false, `dispatchBenchmarkDetail/${payload.type}`);
- },
+ useFetchBenchmarks = () =>
+ useClientDataSWR(evalKeys.benchmarks(), () => agentEvalService.listBenchmarks(), {
+ onSuccess: (data) => {
+ this.#set({ benchmarkList: data, benchmarkListInit: true });
+ },
+ });
- // Internal — update loading state for specific detail
- internal_updateBenchmarkDetailLoading: (id, loading) => {
- set(
- (state) => ({
- loadingBenchmarkDetailIds: loading
- ? [...state.loadingBenchmarkDetailIds, id]
- : state.loadingBenchmarkDetailIds.filter((i) => i !== id),
- }),
- false,
- 'updateBenchmarkDetailLoading',
- );
- },
- });
+ refreshBenchmarks = async () => {
+ await mutate(evalKeys.benchmarks());
+ };
```
- ### Store Guidelines
-
- 1. **SWR keys as constants** at top of file
- 2. **useClientDataSWR** for all data fetching (never useEffect)
- 3. **onSuccess/onData callback** updates store state
- 4. **Refresh methods** use `mutate()` to invalidate cache
- 5. **Loading states** in initialState, updated in onSuccess/onData
- 6. **Mutations** call service, then refresh relevant cache
-
- ---
-
- ## Async Failure Boundary Contract
-
- Every read hook returns an SWR response that includes `error` and `mutate`; the surface must
- consume them. A success-only init flag (`!isInit`, `!map[id]`, `data ?? []`) is not enough:
- when the request fails, that flag often never flips, so the UI paints a permanent skeleton,
- a fake empty state, a false `NotFound`, or a confident zero-value metric.
-
- Use the shared UI primitives:
-
- - `AsyncBoundary` for normal loading / error / empty / data surfaces.
- - `AsyncError` for custom layouts, detail pages, inline load-more failures, or metrics.
+ Read the current [benchmark action](../../../src/store/eval/slices/benchmark/action.ts)
+ for store wiring and the [SWR wrappers](../../../src/libs/swr/index.ts) for their
+ actual options. These links locate implementation; they do not make every existing
+ call site a template to copy unchanged.
- Core precedence for first-load failures:
+ Hooks that share a known parent id can run together. If one request needs a value
+ returned by another, keep its key `null` until that value exists. Do not assume the
+ application wrapper uses upstream SWR's default deduplication interval.
- ```tsx
- const { data, error, isLoading, mutate } = useFetchXxx();
+ For lists cached separately under multiple parents, see
+ [parent-keyed lists](references/walkthrough.md). Ordinary flat lists need no extra layer.
- return (
- <AsyncBoundary
- data={data}
- empty={<EmptyState />}
- error={error}
- isEmpty={!error && data?.length === 0}
- isLoading={isLoading}
- onRetry={() => {
- void mutate();
- }}
- >
- <List items={data ?? []} />
- </AsyncBoundary>
- );
- ```
+ ## Mutations
- Rules:
+ - Call the service, then refresh affected list/detail keys and any related domain
+ whose response changed. Await refresh when subsequent UI behavior depends on it.
+ - Keep pending flags in store state and clear them in `finally`. Use per-id state for
+ row updates/deletes so unrelated rows remain usable; create can use a separate flag
+ because no persistent id exists yet.
+ - For optimistic create/update, use the store's reducer/dispatch convention and
+ restore or revalidate affected state if the service fails. Do not leave a temporary
+ row or a successful-looking edit after rejection.
+ - Delete after server success, following the `zustand` convention. Do not remove the
+ row optimistically or apply create/update's optimistic recipe to deletion.
+ - Let failures reach the caller's error UI; a `finally` block clears pending state
+ but does not by itself recover an optimistic write.
- - Check `error` before empty / `NotFound` / zero defaults. Error is not a kind of empty.
- - Keep already-loaded content on background revalidation failures; only replace the surface
- when there is no settled data to preserve.
- - For detail maps, don't put the error branch after `if (!map[id]) return <Skeleton/>`.
- First-load failures never populate the map, making that error branch unreachable.
- - For infinite scroll / load-more, persist a per-bucket `loadMoreError` and render an
- inline Retry row. Do not let an `IntersectionObserver` silently retry while the error is
- still unresolved.
- - For merged fetched + static lists, branch on the fetched slice's `error` before merging.
- Static fallback rows can make a failed fetch look like a plausible partial catalog.
+ ## Render loading, errors, and settled data
- ## Layer 3: Component Usage
+ Consume the read hook's SWR response. Success-only flags such as `isInit`, missing
+ map entries, and `data ?? []` cannot distinguish an initial request failure from
+ loading or empty results.
- ### Fetching List Data
+ Use `AsyncBoundary` for standard loading/error/empty/data surfaces; use `AsyncError`
+ for custom layouts and inline failures. Pass the original SWR `data` to the boundary:
+ `undefined` means no successful result, whereas `[]` is a settled empty result.
```tsx
- // ✅ CORRECT
const BenchmarkList = () => {
- // 1. Get the hook from store
const useFetchBenchmarks = useEvalStore((s) => s.useFetchBenchmarks);
-
- // 2. Get list data
const benchmarks = useEvalStore((s) => s.benchmarkList);
- const isInit = useEvalStore((s) => s.benchmarkListInit);
-
- // 3. Call the hook (SWR handles the data fetching)
- useFetchBenchmarks();
-
- // 4. Use the data
- if (!isInit) return <Loading />;
- return (
- <div>
- <h2>Total: {benchmarks.length}</h2>
- {benchmarks.map((b) => (
- <BenchmarkCard key={b.id} {...b} />
- ))}
- </div>
- );
- };
- ```
-
- ### Fetching Detail Data
-
- ```tsx
- // ✅ CORRECT
- const BenchmarkDetail = () => {
- const { benchmarkId } = useParams<{ benchmarkId: string }>();
-
- const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
-
- // Detail from map
- const benchmark = useEvalStore((s) =>
- benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
- );
-
- // Per-item loading
- const isLoading = useEvalStore((s) =>
- benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
- );
-
- useFetchBenchmarkDetail(benchmarkId);
-
- if (!benchmark) return <Loading />;
- return (
- <div>
- <h1>{benchmark.name}</h1>
- <p>{benchmark.description}</p>
- {isLoading && <Spinner />}
- </div>
- );
- };
- ```
-
- ### Using Selectors (Recommended)
-
- ```typescript
- // src/store/eval/slices/benchmark/selectors.ts
- export const benchmarkSelectors = {
- getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
- isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
- s.loadingBenchmarkDetailIds.includes(id),
- };
-
- // Component with selectors
- const BenchmarkDetail = () => {
- const { benchmarkId } = useParams();
- const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
- const benchmark = useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!));
-
- useFetchBenchmarkDetail(benchmarkId);
-
- return <div>{benchmark && <h1>{benchmark.name}</h1>}</div>;
- };
- ```
-
- ### Anti-pattern
-
- ```tsx
- // ❌ WRONG — Don't use useEffect for data fetching
- const BenchmarkList = () => {
- const [data, setData] = useState([]);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- setLoading(true);
- lambdaClient.agentEval.listBenchmarks
- .query()
- .then(setData)
- .finally(() => setLoading(false));
- }, []);
-
- return <div>...</div>;
- };
- ```
-
- ### Mutations in Components
-
- ```tsx
- // Create — global mutation flag drives form loading
- const CreateBenchmarkModal = () => {
- const createBenchmark = useEvalStore((s) => s.createBenchmark);
- const isCreating = useEvalStore((s) => s.isCreatingBenchmark);
-
- const handleSubmit = async (values) => {
- try {
- // Optimistic update + refresh happen inside createBenchmark
- await createBenchmark(values);
- message.success('Created successfully');
- onClose();
- } catch (error) {
- message.error('Failed to create');
- }
- };
-
- return (
- <Form onSubmit={handleSubmit} loading={isCreating}>
- ...
- </Form>
- );
- };
-
- // Update / delete — per-item loading so only the row being mutated spins
- const BenchmarkItem = ({ id }: { id: string }) => {
- const updateBenchmark = useEvalStore((s) => s.updateBenchmark);
- const deleteBenchmark = useEvalStore((s) => s.deleteBenchmark);
- const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(id));
-
- const handleUpdate = async (data) => {
- await updateBenchmark({ id, ...data });
- };
-
- const handleDelete = async () => {
- await deleteBenchmark(id);
- };
+ const { data, error, isLoading, mutate } = useFetchBenchmarks();
return (
- <div>
- {isLoading && <Spinner />}
- <button onClick={handleUpdate}>Update</button>
- <button onClick={handleDelete}>Delete</button>
- </div>
- );
- };
- ```
-
- **Why two patterns:** create has no id yet, so a single `isCreatingXxx` flag is enough. Update/delete target a specific row, so global flags would freeze unrelated rows — keep per-item state in `loadingXxxIds`.
-
- ---
-
- ## Need a fuller worked example?
-
- The canonical `Benchmark` example above is the one to copy for a flat list + detail map. If you need to maintain a list **keyed by a parent id** (e.g. `datasetMap[benchmarkId]` because the same shape appears under multiple parents), read [`references/walkthrough.md`](./references/walkthrough.md) — it walks through the full 6 steps (service → reducer → slice → store wiring → selectors → component) for that variant.
-
- ---
-
- ## Common Patterns
-
- ### Pattern 1: Pagination
-
- Cache key array must include every parameter that should trigger a refetch.
-
- ```typescript
- useFetchTestCases: (params: { datasetId: string; limit: number; offset: number }) =>
- useClientDataSWR(
- params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId, params.limit, params.offset] : null,
- () => agentEvalService.listTestCases(params),
- {
- onSuccess: (data) =>
- set({
- testCaseList: data.data,
- testCaseTotal: data.total,
- isLoadingTestCases: false,
- }),
- },
+ <AsyncBoundary
+ data={data}
+ empty={<EmptyState />}
+ error={error}
+ isEmpty={data?.length === 0}
+ isLoading={isLoading}
+ onRetry={() => {
+ void mutate();
+ }}
+ >
+ <BenchmarkCards items={benchmarks} />
+ </AsyncBoundary>
);
- ```
-
- ### Pattern 2: Dependent Fetching
-
- Both hooks run in parallel — SWR dedupes, no manual sequencing needed.
-
- ```tsx
- const BenchmarkDetail = () => {
- const { benchmarkId } = useParams();
- const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
- const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
-
- useFetchBenchmarkDetail(benchmarkId);
- useFetchDatasets(benchmarkId);
-
- return <div>...</div>;
};
```
- ### Pattern 3: Conditional Fetching
-
- Pass `undefined` to disable the hook entirely.
-
- ```tsx
- // only fetch when modal is open AND id present
- useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
- ```
-
- ### Pattern 4: Cross-domain Refresh
-
- ```typescript
- deleteBenchmark: async (id) => {
- await agentEvalService.deleteBenchmark(id);
- await get().refreshBenchmarks();
- await get().refreshDatasets(id); // related cache invalidated too
- };
- ```
-
- ---
-
- ## Migration Guide: useEffect → Store SWR
-
- ### Before (❌ Wrong)
-
- ```tsx
- const TestCaseList = ({ datasetId }: Props) => {
- const [data, setData] = useState<any[]>([]);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- setLoading(true);
- lambdaClient.agentEval.listTestCases
- .query({ datasetId })
- .then((r) => setData(r.data))
- .finally(() => setLoading(false));
- }, [datasetId]);
-
- return <Table data={data} loading={loading} />;
- };
- ```
-
- ### After (✅ Correct)
-
- ```typescript
- // 1. Add service method
- class AgentEvalService {
- async listTestCases(params: { datasetId: string }) {
- return lambdaClient.agentEval.listTestCases.query(params);
- }
- }
-
- // 2. Add store slice hook
- export const createTestCaseSlice: StateCreator<...> = (set) => ({
- useFetchTestCases: (params) =>
- useClientDataSWR(
- params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId] : null,
- () => agentEvalService.listTestCases(params),
- {
- onSuccess: (data) =>
- set({ testCaseList: data.data, isLoadingTestCases: false }),
- },
- ),
- });
-
- // 3. Component reads from store
- const TestCaseList = ({ datasetId }: Props) => {
- const useFetchTestCases = useEvalStore((s) => s.useFetchTestCases);
- const data = useEvalStore((s) => s.testCaseList);
- const loading = useEvalStore((s) => s.isLoadingTestCases);
-
- useFetchTestCases({ datasetId });
-
- return <Table data={data} loading={loading} />;
- };
- ```
-
- ---
-
- ## Troubleshooting
-
- | Symptom | Check |
- | --------------------------- | ------------------------------------------------------------------- |
- | Data never loads | Hook called? Key not `null`/`undefined`? Network tab shows request? |
- | Stale data after mutation | Did `refreshXxx` run? Cache key matches what the hook uses? |
- | Loading state stuck `true` | `onSuccess` writes loading=false? Promise rejected silently? |
- | Detail map missing an entry | Reducer dispatch ran? `isEqual` short-circuited on stale data? |
-
- ---
-
- ## Summary Checklist
-
- When adding new data fetching:
-
- ### Step 1: Types & State
-
- See `store-data-structures` for details.
-
- - [ ] Define types in `@lobechat/types`: Detail type + List item type
- - [ ] State structure: `xxxList: XxxListItem[]`, `xxxDetailMap: Record<string, Xxx>`, `loadingXxxDetailIds: string[]`
- - [ ] Reducer if optimistic updates are needed
-
- ### Step 2: Service Layer
-
- - [ ] Create service in `src/services/xxxService.ts`
- - [ ] Methods: `listXxx()`, `getXxx(id)`, `createXxx()`, `updateXxx()`, `deleteXxx()`
-
- ### Step 3: Store Actions
-
- - [ ] `initialState.ts` with state structure
- - [ ] `action.ts` with:
- - [ ] `useFetchXxxList()`, `useFetchXxxDetail(id)` — SWR hooks
- - [ ] `refreshXxxList()`, `refreshXxxDetail(id)` — cache invalidation
- - [ ] CRUD methods calling service
- - [ ] `internal_dispatch`, `internal_updateLoading` if using reducer
- - [ ] `selectors.ts` (optional but recommended)
- - [ ] Integrate slice into main store + initialState
-
- ### Step 4: Component Usage
-
- - [ ] Use store hooks (NOT useEffect)
- - [ ] Destructure and consume `error` / `mutate`; wrap first-load surfaces in `AsyncBoundary`
- or render `AsyncError` before empty / `NotFound` / zero defaults
- - [ ] List pages: access `xxxList` array
- - [ ] Detail pages: access `xxxDetailMap[id]`
- - [ ] Use loading states for UI feedback
- - [ ] Infinite-scroll failures persist as a visible tail Retry row, not a silent `catch`
- - [ ] Changes affecting home first paint or persisted display data follow the guidance above
+ The example's `EmptyState` and `BenchmarkCards` stand for the surface's existing
+ renderers. Follow these distinctions when adapting it:
- **Mental model:** Types → Service → Reducer → Slice → Component 🎯
+ - Show first-load errors before empty / `NotFound` / zero-value defaults. Do not put
+ an error branch after `if (!map[id]) return <Skeleton />`; failure may never fill the map.
+ - Preserve settled content during background revalidation failures, including a
+ successfully loaded empty list. A retry in flight should show pending feedback.
+ - In a fetched + static list, use the fetched slice's request state before combining
+ rows. Static rows must not hide a failed request.
+ - For infinite scroll, keep a per-bucket `loadMoreError` and show an inline Retry row.
+ Suspend observer-triggered retries while that error is unresolved.
+ - A closed modal or absent id disables a request; it is not evidence of a missing record.
- ---
+ ## Migrating an existing fetch
- ## Related Skills
+ Move the API call into its service and the request into a store SWR hook. Reuse the
+ existing state shape and action organization unless they need to change for the task.
+ Replace the component's effect/local fetch state with that hook and selectors, then
+ connect `error`, retry, and pending feedback. Refresh the same cache key after writes.
- - **`store-data-structures`** — How to structure List and Detail data in stores
- - **`zustand`** — General Zustand patterns and best practices
+ Check initial failure, retry, settled-empty results, background failure, and switching
+ ids against the surface being changed. For stale data, compare read/refresh keys and
+ the store bucket they update; for stuck loading, inspect rejected requests as well as
+ success callbacks. Do not add a second fetch or another loading flag to mask the cause.