lobehub/lobehub/.agents/skills/data-fetching-architecture/SKILL.md
data-fetching-architecture
LobeHub data-fetching pipeline guide. Use for service layer, Zustand store, SWR, lambdaClient, useClientDataSWR, useFetchXxx hooks, or migrating useEffect fetches.
- Source repository stars
- 81,246
- Declared platforms
- 0
- Static risk flags
- 0
- Last source update
- 2026-08-04
- Source checked
- 2026-08-04
Decision brief
What it does—and where it fits
Related: store-data-structures covers List vs Detail data shape rationale (Map vs Array).
Not for
- Tasks that require unconfirmed production actions or broad system permissions.
- Environments where the pinned source and install steps cannot be inspected.
Compatibility matrix
Platform support, with evidence labels
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
Inspect first. Install second.
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/lobehub/lobehub --skill ".agents/skills/data-fetching-architecture"Inspect the Agent Skill "data-fetching-architecture" from https://github.com/lobehub/lobehub/blob/eb65d06d050732503da3be00f2902dc4483f53bd/.agents/skills/data-fetching-architecture/SKILL.md at commit eb65d06d050732503da3be00f2902dc4483f53bd. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
What the source asks the agent to do
- 01
Layer 3: Component Usage
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.
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. - 02
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, loadingXxxDetailIds: string[][ ] Reducer if optimistic updates are needed - 03
Step 2: Service Layer
[ ] Create service in src/services/xxxService.ts
[ ] Create service in src/services/xxxService.ts[ ] Methods: listXxx(), getXxx(id), createXxx(), updateXxx(), deleteXxx()- [ ] Create service in src/services/xxxService.ts - [ ] Methods: listXxx(), getXxx(id), createXxx(), updateXxx(), deleteXxx() - 04
Step 3: Store Actions
[ ] initialState.ts with state structure
[ ] initialState.ts with state structure[ ] action.ts with:[ ] useFetchXxxList(), useFetchXxxDetail(id) — SWR hooks - 05
Step 4: Component Usage
Mental model: Types → Service → Reducer → Slice → Component 🎯
[ ] Use store hooks (NOT useEffect)[ ] Destructure and consume error / mutate; wrap first-load surfaces in AsyncBoundary[ ] List pages: access xxxList array
Permission review
Static risk signals and limitations
No configured static risk pattern was detected
This is not proof of safety. Runtime behavior, indirect dependencies, and hidden external systems are outside the static scan.
Evidence record
Why each signal appears
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 86/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 81,246 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Provenance and original SKILL.md
- Repository
- lobehub/lobehub
- Skill path
- .agents/skills/data-fetching-architecture/SKILL.md
- Commit
- eb65d06d050732503da3be00f2902dc4483f53bd
- License
- NOASSERTION
- Collected
- 2026-08-04
- Default branch
- canary
View the original SKILL.md
LobeHub Data Fetching Architecture
Related:
store-data-structurescovers List vs Detail data shape rationale (Map vs Array).
Architecture Overview
┌─────────────┐
│ Component │
└──────┬──────┘
│ 1. Call useFetchXxx hook from store
↓
┌──────────────────┐
│ Zustand Store │
│ (State + Hook) │
└──────┬───────────┘
│ 2. useClientDataSWR calls service
↓
┌──────────────────┐
│ Service Layer │
│ (xxxService) │
└──────┬───────────┘
│ 3. Call lambdaClient
↓
┌──────────────────┐
│ lambdaClient │
│ (TRPC Client) │
└──────────────────┘
Core Principles
✅ DO
- Use Service Layer for all API calls
- Use Store SWR Hooks for data fetching (not useEffect)
- Use proper data structures — see
store-data-structuresskill for List vs Detail patterns - Use lambdaClient.mutate for write operations (create/update/delete)
- Use lambdaClient.query only inside service methods
- Naming convention — read hooks are
useFetchXxx, cache invalidation helpers arerefreshXxx(e.g.useFetchBenchmarks/refreshBenchmarks). Mutations then chainrefreshXxx()after the service call.
❌ DON'T
- Never use useEffect for data fetching
- Never call lambdaClient directly in components or stores
- Never use useState for server data
- Never mix data structure patterns — follow
store-data-structuresskill
Layer 1: Service Layer
Purpose
- Encapsulate all API calls to lambdaClient
- Provide clean, typed interfaces
- Single source of truth for API operations
Service Structure
// 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
- One service per domain (e.g., agentEval, ragEval, aiAgent)
- Export singleton instance (
export const xxxService = new XxxService()) - Method names match operations (list, get, create, update, delete)
- 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
// 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[];
// Mutation states
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}
For complete initialState, reducer, and internal dispatch patterns, see the
store-data-structuresskill.
Actions
// 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}`);
},
// 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',
);
},
});
Store Guidelines
- SWR keys as constants at top of file
- useClientDataSWR for all data fetching (never useEffect)
- onSuccess/onData callback updates store state
- Refresh methods use
mutate()to invalidate cache - Loading states in initialState, updated in onSuccess/onData
- 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:
AsyncBoundaryfor normal loading / error / empty / data surfaces.AsyncErrorfor custom layouts, detail pages, inline load-more failures, or metrics.
Core precedence for first-load failures:
const { data, error, isLoading, mutate } = useFetchXxx();
return (
<AsyncBoundary
data={data}
empty={<EmptyState />}
error={error}
isEmpty={!error && data?.length === 0}
isLoading={isLoading}
onRetry={() => {
void mutate();
}}
>
<List items={data ?? []} />
</AsyncBoundary>
);
Rules:
- Check
errorbefore 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
loadMoreErrorand render an inline Retry row. Do not let anIntersectionObserversilently retry while the error is still unresolved. - For merged fetched + static lists, branch on the fetched slice's
errorbefore merging. Static fallback rows can make a failed fetch look like a plausible partial catalog.
Layer 3: Component Usage
Fetching List Data
// ✅ 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
// ✅ 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)
// 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
// ❌ 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
// 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);
};
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 — 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.
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,
}),
},
);
Pattern 2: Dependent Fetching
Both hooks run in parallel — SWR dedupes, no manual sequencing needed.
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.
// only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
Pattern 4: Cross-domain Refresh
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)
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)
// 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.tswith state structure -
action.tswith:-
useFetchXxxList(),useFetchXxxDetail(id)— SWR hooks -
refreshXxxList(),refreshXxxDetail(id)— cache invalidation - CRUD methods calling service
-
internal_dispatch,internal_updateLoadingif 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 inAsyncBoundaryor renderAsyncErrorbefore empty /NotFound/ zero defaults - List pages: access
xxxListarray - 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
Mental model: Types → Service → Reducer → Slice → Component 🎯
Related Skills
store-data-structures— How to structure List and Detail data in storeszustand— General Zustand patterns and best practices