LobeHub Data Fetching Architecture
Related Skills:
- store-data-structures
- How to structure List and Detail data in stores (Map vs Array patterns)
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-structures skill for List vs Detail patterns
-
Use lambdaClient.mutate for write operations (create/update/delete)
-
Use lambdaClient.query only inside service methods
❌ 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-structures skill
Note: For data structure patterns (Map vs Array, List vs Detail), see the store-data-structures skill.
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 import { lambdaClient } from '@/libs/trpc/client';
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
Data Structure: See store-data-structures skill for how to structure List and Detail data.
Store Structure Overview
// src/store/eval/slices/benchmark/initialState.ts import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
export interface BenchmarkSliceState { // List data - simple array (see store-data-structures skill) benchmarkList: AgentEvalBenchmarkListItem[]; benchmarkListInit: boolean;
// Detail data - map for caching (see store-data-structures skill) 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-structures skill.
Create Actions
// src/store/eval/slices/benchmark/action.ts import type { SWRResponse } from 'swr'; import type { StateCreator } from 'zustand/vanilla'; import isEqual from 'fast-deep-equal';
import { mutate, useClientDataSWR } from '@/libs/swr'; import { agentEvalService } from '@/services/agentEval'; import type { EvalStore } from '@/store/eval/store'; import { benchmarkDetailReducer, type BenchmarkDetailDispatch } from './reducer';
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 - for write operations 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, [['zustand/devtools', never]], [], BenchmarkAction
= (set, get) => ({ // Fetch list - Simple array useFetchBenchmarks: () => { return useClientDataSWR(FETCH_BENCHMARKS_KEY, () => agentEvalService.listBenchmarks(), { onSuccess: (data: any) => { set( { benchmarkList: data, benchmarkListInit: true, }, false, 'useFetchBenchmarks/success', ); }, }); },
// Fetch detail - Map with dispatch useFetchBenchmarkDetail: (id) => { return useClientDataSWR( id ? [FETCH_BENCHMARK_DETAIL_KEY, id] : null, () => agentEvalService.getBenchmark(id!), { onSuccess: (data: any) => { get().internal_dispatchBenchmarkDetail({ type: 'setBenchmarkDetail', id: id!, value: data, }); get().internal_updateBenchmarkDetailLoading(id!, false); }, }, ); },
// Refresh methods refreshBenchmarks: async () => { await mutate(FETCH_BENCHMARKS_KEY); },
refreshBenchmarkDetail: async (id) => { await 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 - With optimistic update for detail 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 - Refresh list and remove from detail map deleteBenchmark: async (id) => { // 1. Optimistic update get().internal_dispatchBenchmarkDetail({ type: 'deleteBenchmarkDetail', id, });
// 2. Set loading
get().internal_updateBenchmarkDetailLoading(id, true);
try {
// 3. Call service
await agentEvalService.deleteBenchmark(id);
// 4. Refresh list
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);
// No need to update if map is the same
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) => { if (loading) { return { loadingBenchmarkDetailIds: [...state.loadingBenchmarkDetailIds, id] }; } return { loadingBenchmarkDetailIds: 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 callback updates store state
-
Refresh methods use mutate() to invalidate cache
-
Loading states in initialState, updated in onSuccess
-
Mutations call service, then refresh relevant cache
Layer 3: Component Usage
Data Fetching in Components
Fetching List Data:
// Component using list data - ✅ CORRECT import { useEvalStore } from '@/store/eval';
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:
// Component using detail data from map - ✅ CORRECT import { useEvalStore } from '@/store/eval'; import { useParams } from 'react-router-dom';
const BenchmarkDetail = () => { const { benchmarkId } = useParams<{ benchmarkId: string }>();
// 1. Get the hook const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
// 2. Get detail from map const benchmark = useEvalStore((s) => benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined, );
// 3. Get loading state const isLoading = useEvalStore((s) => benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false, );
// 4. Call the hook useFetchBenchmarkDetail(benchmarkId);
// 5. Use the data 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>; };
What NOT to Do
// ❌ WRONG - Don't use useEffect for data fetching const BenchmarkList = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false);
useEffect(() => { const fetchData = async () => { setLoading(true); const result = await lambdaClient.agentEval.listBenchmarks.query(); setData(result); setLoading(false); }; fetchData(); }, []);
return <div>...</div>; };
Mutations in Components
// Mutations (Create/Update/Delete) with optimistic updates - ✅ CORRECT import { useEvalStore } from '@/store/eval'; import { benchmarkSelectors } from '@/store/eval/selectors';
const CreateBenchmarkModal = () => { const createBenchmark = useEvalStore((s) => s.createBenchmark);
const handleSubmit = async (values) => { try { // Optimistic update happens inside createBenchmark await createBenchmark(values); message.success('Created successfully'); onClose(); } catch (error) { message.error('Failed to create'); } };
return <Form onSubmit={handleSubmit}>...</Form>; };
// With loading state for specific item const BenchmarkItem = ({ id }: { id: string }) => { const updateBenchmark = useEvalStore((s) => s.updateBenchmark); const deleteBenchmark = useEvalStore((s) => s.deleteBenchmark); const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmark(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> ); };
Data Structures: For detailed comparison of List vs Detail patterns, see the store-data-structures skill.
Complete Example: Adding a New Feature
Scenario: Add "Dataset" data fetching with optimistic updates
Step 1: Create Service
// src/services/agentEval.ts class AgentEvalService { // ... existing methods ...
// Add new methods async listDatasets(benchmarkId: string) { return lambdaClient.agentEval.listDatasets.query({ benchmarkId }); }
async getDataset(id: string) { return lambdaClient.agentEval.getDataset.query({ id }); }
async createDataset(params: CreateDatasetParams) { return lambdaClient.agentEval.createDataset.mutate(params); } }
Step 2: Create Reducer
// src/store/eval/slices/dataset/reducer.ts import { produce } from 'immer'; import type { Dataset } from '@/types/dataset';
type AddDatasetAction = { type: 'addDataset'; value: Dataset; };
type UpdateDatasetAction = { id: string; type: 'updateDataset'; value: Partial<Dataset>; };
type DeleteDatasetAction = { id: string; type: 'deleteDataset'; };
export type DatasetDispatch = AddDatasetAction | UpdateDatasetAction | DeleteDatasetAction;
export const datasetReducer = (state: Dataset[] = [], payload: DatasetDispatch): Dataset[] => { switch (payload.type) { case 'addDataset': { return produce(state, (draft) => { draft.unshift(payload.value); }); }
case 'updateDataset': {
return produce(state, (draft) => {
const index = draft.findIndex((item) => item.id === payload.id);
if (index !== -1) {
draft[index] = { ...draft[index], ...payload.value };
}
});
}
case 'deleteDataset': {
return produce(state, (draft) => {
const index = draft.findIndex((item) => item.id === payload.id);
if (index !== -1) {
draft.splice(index, 1);
}
});
}
default:
return state;
} };
Step 3: Create Store Slice
// src/store/eval/slices/dataset/initialState.ts import type { Dataset } from '@/types/dataset';
export interface DatasetData { currentPage: number; hasMore: boolean; isLoading: boolean; items: Dataset[]; pageSize: number; total: number; }
export interface DatasetSliceState { // Map keyed by benchmarkId datasetMap: Record<string, DatasetData>; // Simple state for single item (read-only, used in modals) datasetDetail: Dataset | null; isLoadingDatasetDetail: boolean; loadingDatasetIds: string[]; }
export const datasetInitialState: DatasetSliceState = { datasetMap: {}, datasetDetail: null, isLoadingDatasetDetail: false, loadingDatasetIds: [], };
// src/store/eval/slices/dataset/action.ts import type { SWRResponse } from 'swr'; import type { StateCreator } from 'zustand/vanilla'; import isEqual from 'fast-deep-equal';
import { mutate, useClientDataSWR } from '@/libs/swr'; import { agentEvalService } from '@/services/agentEval'; import type { EvalStore } from '@/store/eval/store'; import { datasetReducer, type DatasetDispatch } from './reducer';
const FETCH_DATASETS_KEY = 'FETCH_DATASETS'; const FETCH_DATASET_DETAIL_KEY = 'FETCH_DATASET_DETAIL';
export interface DatasetAction { // SWR Hooks useFetchDatasets: (benchmarkId?: string) => SWRResponse; useFetchDatasetDetail: (id?: string) => SWRResponse;
// Refresh methods refreshDatasets: (benchmarkId: string) => Promise<void>; refreshDatasetDetail: (id: string) => Promise<void>;
// Mutations createDataset: (params: any) => Promise<any>; updateDataset: (params: any) => Promise<void>; deleteDataset: (id: string, benchmarkId: string) => Promise<void>;
// Internal methods internal_dispatchDataset: (payload: DatasetDispatch, benchmarkId: string) => void; internal_updateDatasetLoading: (id: string, loading: boolean) => void; }
export const createDatasetSlice: StateCreator< EvalStore, [['zustand/devtools', never]], [], DatasetAction
= (set, get) => ({ // Fetch list with Map useFetchDatasets: (benchmarkId) => { return useClientDataSWR( benchmarkId ? [FETCH_DATASETS_KEY, benchmarkId] : null, () => agentEvalService.listDatasets(benchmarkId!), { onSuccess: (data: any) => { set( { datasetMap: { ...get().datasetMap, [benchmarkId!]: { currentPage: 1, hasMore: false, isLoading: false, items: data, pageSize: data.length, total: data.length, }, }, }, false, 'useFetchDatasets/success', ); }, }, ); },
// Fetch single item (for modal display) useFetchDatasetDetail: (id) => { return useClientDataSWR( id ? [FETCH_DATASET_DETAIL_KEY, id] : null, () => agentEvalService.getDataset(id!), { onSuccess: (data: any) => { set( { datasetDetail: data, isLoadingDatasetDetail: false }, false, 'useFetchDatasetDetail/success', ); }, }, ); },
refreshDatasets: async (benchmarkId) => { await mutate([FETCH_DATASETS_KEY, benchmarkId]); },
refreshDatasetDetail: async (id) => { await mutate([FETCH_DATASET_DETAIL_KEY, id]); },
// CREATE with optimistic update createDataset: async (params) => { const tmpId = Date.now().toString(); const { benchmarkId } = params;
get().internal_dispatchDataset(
{
type: 'addDataset',
value: { ...params, id: tmpId, createdAt: Date.now() } as any,
},
benchmarkId,
);
get().internal_updateDatasetLoading(tmpId, true);
try {
const result = await agentEvalService.createDataset(params);
await get().refreshDatasets(benchmarkId);
return result;
} finally {
get().internal_updateDatasetLoading(tmpId, false);
}
},
// UPDATE with optimistic update updateDataset: async (params) => { const { id, benchmarkId } = params;
get().internal_dispatchDataset(
{
type: 'updateDataset',
id,
value: params,
},
benchmarkId,
);
get().internal_updateDatasetLoading(id, true);
try {
await agentEvalService.updateDataset(params);
await get().refreshDatasets(benchmarkId);
} finally {
get().internal_updateDatasetLoading(id, false);
}
},
// DELETE with optimistic update deleteDataset: async (id, benchmarkId) => { get().internal_dispatchDataset( { type: 'deleteDataset', id, }, benchmarkId, );
get().internal_updateDatasetLoading(id, true);
try {
await agentEvalService.deleteDataset(id);
await get().refreshDatasets(benchmarkId);
} finally {
get().internal_updateDatasetLoading(id, false);
}
},
// Internal - Dispatch to reducer internal_dispatchDataset: (payload, benchmarkId) => { const currentData = get().datasetMap[benchmarkId]; const nextItems = datasetReducer(currentData?.items, payload);
if (isEqual(nextItems, currentData?.items)) return;
set(
{
datasetMap: {
...get().datasetMap,
[benchmarkId]: {
...currentData,
currentPage: currentData?.currentPage ?? 1,
hasMore: currentData?.hasMore ?? false,
isLoading: false,
items: nextItems,
pageSize: currentData?.pageSize ?? nextItems.length,
total: currentData?.total ?? nextItems.length,
},
},
},
false,
`dispatchDataset/${payload.type}`,
);
},
// Internal - Update loading state internal_updateDatasetLoading: (id, loading) => { set( (state) => { if (loading) { return { loadingDatasetIds: [...state.loadingDatasetIds, id] }; } return { loadingDatasetIds: state.loadingDatasetIds.filter((i) => i !== id), }; }, false, 'updateDatasetLoading', ); }, });
Step 3: Integrate into Store
// src/store/eval/store.ts import { createDatasetSlice, type DatasetAction } from './slices/dataset/action';
export type EvalStore = EvalStoreState & BenchmarkAction & DatasetAction & // Add here RunAction;
const createStore: StateCreator<EvalStore, [['zustand/devtools', never]]> = (set, get, store) => ({ ...initialState, ...createBenchmarkSlice(set, get, store), ...createDatasetSlice(set, get, store), // Add here ...createRunSlice(set, get, store), });
// src/store/eval/initialState.ts import { datasetInitialState, type DatasetSliceState } from './slices/dataset/initialState';
export interface EvalStoreState extends BenchmarkSliceState, DatasetSliceState { // ... }
export const initialState: EvalStoreState = { ...benchmarkInitialState, ...datasetInitialState, // Add here ...runInitialState, };
Step 4: Create Selectors (Optional but Recommended)
// src/store/eval/slices/dataset/selectors.ts import type { EvalStore } from '@/store/eval/store';
export const datasetSelectors = { getDatasetData: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId],
getDatasets: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId]?.items ?? [],
isLoadingDataset: (id: string) => (s: EvalStore) => s.loadingDatasetIds.includes(id), };
Step 5: Use in Component
// Component - List with Map import { useEvalStore } from '@/store/eval'; import { datasetSelectors } from '@/store/eval/selectors';
const DatasetList = ({ benchmarkId }: { benchmarkId: string }) => { const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets); const datasets = useEvalStore(datasetSelectors.getDatasets(benchmarkId)); const datasetData = useEvalStore(datasetSelectors.getDatasetData(benchmarkId));
useFetchDatasets(benchmarkId);
if (datasetData?.isLoading) return <Loading />;
return ( <div> <h2>Total: {datasetData?.total ?? 0}</h2> <List data={datasets} /> </div> ); };
// Component - Single item (for modal) const DatasetImportModal = ({ open, datasetId }: Props) => { const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail); const dataset = useEvalStore((s) => s.datasetDetail); const isLoading = useEvalStore((s) => s.isLoadingDatasetDetail);
// Only fetch when modal is open useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
return ( <Modal open={open}> {isLoading ? <Loading /> : <div>{dataset?.name}</div>} </Modal> ); };
Common Patterns
Pattern 1: List + Detail
// List with pagination useFetchTestCases: (params) => { const { datasetId, limit, offset } = params; return useClientDataSWR( datasetId ? [FETCH_TEST_CASES_KEY, datasetId, limit, offset] : null, () => agentEvalService.listTestCases({ datasetId, limit, offset }), { onSuccess: (data: any) => { set( { testCaseList: data.data, testCaseTotal: data.total, isLoadingTestCases: false, }, false, 'useFetchTestCases/success', ); }, }, ); };
Pattern 2: Dependent Fetching
// Component const BenchmarkDetail = () => { const { benchmarkId } = useParams();
const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail); const benchmark = useEvalStore((s) => s.benchmarkDetail);
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets); const datasets = useEvalStore((s) => s.datasetList);
// Fetch benchmark first useFetchBenchmarkDetail(benchmarkId);
// Then fetch datasets for this benchmark useFetchDatasets(benchmarkId);
return <div>...</div>; };
Pattern 3: Conditional Fetching
// Only fetch when modal is open const DatasetImportModal = ({ open, datasetId }: Props) => { const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail); const dataset = useEvalStore((s) => s.datasetDetail);
// Only fetch when open AND datasetId exists useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
return <Modal open={open}>...</Modal>; };
Pattern 4: Refresh After Mutation
// Store action createDataset: async (params) => { const result = await agentEvalService.createDataset(params); // Refresh the list after creation await get().refreshDatasets(params.benchmarkId); return result; };
deleteDataset: async (id, benchmarkId) => { await agentEvalService.deleteDataset(id); // Refresh the list after deletion await get().refreshDatasets(benchmarkId); };
Migration Guide: useEffect → Store SWR
Before (❌ Wrong)
const TestCaseList = ({ datasetId }: Props) => { const [data, setData] = useState<any[]>([]); const [loading, setLoading] = useState(false);
useEffect(() => { const fetchData = async () => { setLoading(true); try { const result = await lambdaClient.agentEval.listTestCases.query({ datasetId, }); setData(result.data); } finally { setLoading(false); } }; fetchData(); }, [datasetId]);
return <Table data={data} loading={loading} />; };
After (✅ Correct)
// 1. Create service method class AgentEvalService { async listTestCases(params: { datasetId: string }) { return lambdaClient.agentEval.listTestCases.query(params); } }
// 2. Create store slice export const createTestCaseSlice: StateCreator<...> = (set) => ({ useFetchTestCases: (params) => { return useClientDataSWR( params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId] : null, () => agentEvalService.listTestCases(params), { onSuccess: (data: any) => { set( { testCaseList: data.data, isLoadingTestCases: false }, false, 'useFetchTestCases/success', ); }, }, ); }, });
// 3. Use in component 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} />; };
Best Practices
✅ DO
-
Always use service layer - Never call lambdaClient directly in stores/components
-
Use SWR hooks in stores - Not useEffect in components
-
Clear naming - useFetchXxx for hooks, refreshXxx for cache invalidation
-
Proper cache keys - Use constants, include parameters in array form
-
Update state in onSuccess - Set loading states and data
-
Refresh after mutations - Call refresh methods after create/update/delete
-
Handle loading states - Provide loading indicators to users
❌ DON'T
-
Don't use useEffect for data fetching
-
Don't use useState for server data
-
Don't call lambdaClient directly in components or stores
-
Don't forget to refresh cache after mutations
-
Don't duplicate state - Use store as single source of truth
Troubleshooting
Problem: Data not loading
Check:
-
Is the hook being called? useFetchXxx()
-
Is the key valid? (not null/undefined)
-
Is the service method correct?
-
Check browser network tab for API calls
Problem: Data not refreshing after mutation
Check:
-
Did you call refreshXxx() after mutation?
-
Is the cache key the same in both hook and refresh?
-
Check devtools for state updates
Problem: Loading state stuck
Check:
-
Is onSuccess updating isLoadingXxx: false ?
-
Is there an error in the API call?
-
Check error boundary or console
Summary Checklist
When implementing new data fetching:
Step 1: Data Structures
See store-data-structures skill for detailed patterns
-
Define types in @lobechat/types :
-
Detail type (e.g., AgentEvalBenchmark )
-
List item type (e.g., AgentEvalBenchmarkListItem )
-
Design state structure:
-
List: xxxList: XxxListItem[]
-
Detail: xxxDetailMap: Record<string, Xxx>
-
Loading: loadingXxxDetailIds: string[]
-
Create reducer if optimistic updates needed
Step 2: Service Layer
-
Create service in src/services/xxxService.ts
-
Add methods:
-
listXxx()
-
fetch list
-
getXxx(id)
-
fetch detail
-
createXxx() , updateXxx() , deleteXxx()
-
mutations
Step 3: Store Actions
-
Create initialState.ts with state structure
-
Create action.ts with:
-
useFetchXxxList()
-
list SWR hook
-
useFetchXxxDetail(id)
-
detail SWR hook
-
refreshXxxList() , refreshXxxDetail(id)
-
cache invalidation
-
CRUD methods calling service
-
internal_dispatch and internal_updateLoading if using reducer
-
Create selectors.ts (optional but recommended)
-
Integrate slice into main store
Step 4: Component Usage
-
Use store hooks (NOT useEffect)
-
List pages: access xxxList array
-
Detail pages: access xxxDetailMap[id]
-
Use loading states for UI feedback
Remember: Types → Service → Store (SWR + Reducer) → Component 🎯
Key Architecture Patterns
-
Service Layer: Clean API abstraction (xxxService )
-
Data Structures: List arrays + Detail maps (see store-data-structures skill)
-
SWR Hooks: Automatic caching and revalidation (useFetchXxx )
-
Cache Invalidation: Manual refresh methods (refreshXxx )
-
Optimistic Updates: Update UI immediately, then sync with server
-
Loading States: Per-item loading for better UX
Related Skills
-
store-data-structures
-
How to structure List and Detail data in stores
-
zustand
-
General Zustand patterns and best practices