LobeHub Data Fetching Architecture
SkillDev toolsUse for client APIs, SWR hooks, cache invalidation, async errors, useEffect migration, home first paint and persistent caches.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LobeHub Data Fetching Architecture skill
What this skill tells your AI
The instructions your AI receives, as published by find-xposed-magisk/lobe-chat in .agents/skills/data-fetching-architecture/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 30
- Forks
- 9
- Last commit
- Sep 2026
Others that do the same job
Advanced
- Catalog kind
- skill
- Gateway key
data-fetching-architecture- Source
- github.com/find-xposed-magisk/lobe-chat