关于 data-fetching
面向 LobeHub 开发者的数据获取技能,说明 React 应用中的分层实现方式。它要求通过服务层和 Zustand Store 的 SWR Hook 管理请求,并规范缓存刷新与变更操作。
主要功能
- 01通过服务层调用 API
- 02Zustand Store 的 SWR Hook
- 03缓存失效约定
- 04列表与详情状态模式
- 05分页与条件获取
适用场景
- 01添加列表和详情数据流
- 02将获取逻辑从 useEffect 迁出
- 03变更后刷新相关缓存
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ lobehub/ lobehub --skill data-fetching安装后在 Claude Code 中输入/data-fetching确认已加载。
附带文件references/walkthrough.md
详细介绍
LobeHub 数据获取架构
相关内容:
store-data-structures介绍了列表与详情数据结构的设计依据(Map 与 Array)。
架构概览
┌─────────────┐
│ Component │
└──────┬──────┘
│ 1. Call useFetchXxx hook from store
↓
┌──────────────────┐
│ Zustand Store │
│ (State + Hook) │
└──────┬───────────┘
│ 2. useClientDataSWR calls service
↓
┌──────────────────┐
│ Service Layer │
│ (xxxService) │
└──────┬───────────┘
│ 3. Call lambdaClient
↓
┌──────────────────┐
│ lambdaClient │
│ (TRPC Client) │
└──────────────────┘
核心原则
✅ 应该这样做
- 使用服务层处理所有 API 调用
- 使用 Store SWR Hooks 获取数据(不要使用 useEffect)
- 使用合适的数据结构 — 列表与详情模式请参阅
store-data-structuresskill - 使用 lambdaClient.mutate 执行写入操作(创建/更新/删除)
- 仅在服务方法中使用 lambdaClient.query
- 命名约定 — 读取 Hooks 使用
useFetchXxx,缓存失效辅助函数使用refreshXxx(例如useFetchBenchmarks/refreshBenchmarks)。变更操作应在调用服务后接着调用refreshXxx()。
❌ 不要这样做
- 切勿使用 useEffect 获取数据
- 切勿在组件或 Store 中直接调用 lambdaClient
- 切勿使用 useState 存储服务端数据
- 切勿混用数据结构模式 — 请遵循
store-data-structuresskill
第 1 层:服务层
目的
- 封装所有对 lambdaClient 的 API 调用
- 提供简洁、类型明确的接口
- 作为 API 操作的唯一事实来源
服务结构
// 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();
服务指南
- 每个领域使用一个服务(例如,agentEval、ragEval、aiAgent)
- 导出单例实例(
export const xxxService = new XxxService()) - 方法名与操作相对应(list、get、create、update、delete)
- 明确参数类型(复杂参数使用接口)
第 2 层:带 SWR Hooks 的 Store
目的
- 管理客户端状态
- 提供用于数据获取的 SWR Hooks
- 处理缓存失效
状态结构
// 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;
}
关于完整的 initialState、reducer 和内部 dispatch 模式,请参阅
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 指南
- 将 SWR 键定义为常量,放在文件顶部
- 所有数据获取都使用 useClientDataSWR(绝不使用 useEffect)
- onSuccess 回调更新 Store 状态
- 刷新方法使用
mutate()使缓存失效 - 在 initialState 中定义加载状态,并在 onSuccess 中更新
- 变更操作调用服务,然后刷新相关缓存
第 3 层:组件用法
获取列表数据
// ✅ 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>
);
};
获取详情数据
// ✅ 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>
);
};
使用 Selectors(推荐)
// 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>;
};
反模式
// ❌ 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>;
};
在组件中执行变更操作
// 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>
);
};
为什么采用两种模式: create 尚无 id,因此一个 isCreatingXxx 标记就足够了。update/delete 则针对特定行;全局标记会导致无关行也被冻结,因此应将每项状态保存在 loadingXxxIds 中。
需要更完整的示例吗?
上面的标准 Benchmark 示例可用于实现扁平列表与详情 Map。如果你需要维护按父级 id 索引的列表(例如 datasetMap[benchmarkId],因为相同的数据结构会出现在多个父级下),请阅读 references/walkthrough.md — 其中通过完整的 6 个步骤(服务 → reducer → slice → Store 接线 → selectors → 组件)介绍了这一变体。
常见模式
模式 1:分页
缓存键数组必须包含所有应触发重新获取的参数。
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,
}),
},
);
模式 2:依赖式获取
两个 Hooks 会并行运行 — SWR 会进行去重,无需手动排序。
const BenchmarkDetail = () => {
const { benchmarkId } = useParams();
const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
useFetchBenchmarkDetail(benchmarkId);
useFetchDatasets(benchmarkId);
return <div>...</div>;
};
模式 3:条件式获取
传入 undefined 即可完全禁用该 Hook。
// only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
模式 4:跨领域刷新
deleteBenchmark: async (id) => {
await agentEvalService.deleteBenchmark(id);
await get().refreshBenchmarks();
await get().refreshDatasets(id); // related cache invalidated too
};
迁移指南:从 useEffect 迁移到 Store SWR
之前(❌ 错误)
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} />;
};
之后(✅ 正确)
// 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} />;
};
故障排查
| 症状 | 检查 |
|---|---|
| 数据始终无法加载 | 是否调用了 Hook?Key 是否不是 null/undefined?网络面板是否显示请求? |
| 修改后数据过时 | 是否运行了 refreshXxx?缓存 Key 是否与 Hook 使用的 Key 一致? |
加载状态一直为 true | onSuccess 是否将 loading 设置为 false?Promise 是否被静默拒绝? |
| 详情映射中缺少条目 | 是否执行了 Reducer dispatch?isEqual 是否因数据过时而提前返回? |
总结清单
添加新的数据获取功能时:
第 1 步:类型与状态
详情请参阅 store-data-structures。
- 在
@lobechat/types中定义类型:详情类型 + 列表项类型 - 状态结构:
xxxList: XxxListItem[]、xxxDetailMap: Record<string, Xxx>、loadingXxxDetailIds: string[] - 如果需要乐观更新,则添加 Reducer
第 2 步:服务层
- 在
src/services/xxxService.ts中创建服务 - 方法:
listXxx()、getXxx(id)、createXxx()、updateXxx()、deleteXxx()
第 3 步:Store Actions
-
initialState.ts中包含状态结构 -
action.ts中包含:-
useFetchXxxList()、useFetchXxxDetail(id)— SWR Hooks -
refreshXxxList()、refreshXxxDetail(id)— 使缓存失效 - 调用服务的 CRUD 方法
- 如果使用 Reducer,则包含
internal_dispatch、internal_updateLoading
-
-
selectors.ts(可选,但建议添加) - 将 Slice 集成到主 Store 和 initialState 中
第 4 步:组件用法
- 使用 Store Hooks(不要使用 useEffect)
- 列表页面:访问
xxxList数组 - 详情页面:访问
xxxDetailMap[id] - 使用加载状态为 UI 提供反馈
思维模型: 类型 → 服务 → Reducer → Slice → 组件 🎯
相关技能
store-data-structures— 如何在 Store 中组织列表和详情数据zustand— Zustand 的通用模式和最佳实践