
Store Data Structures
- 12 installs
- 81.3k repo stars
- Updated August 5, 2026
- lobehub/lobe-chat
This is a copy of store-data-structures by lobehub - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
store-data-structures is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- store-data-structures
- AI & Agent Building
- AI-coding skill
Store Data Structures by the numbers
- 12 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lobehub/lobe-chat --skill store-data-structuresAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 81.3k |
| Last updated | August 5, 2026 |
| Repository | lobehub/lobe-chat ↗ |
What it does
Helps with ai & agent building tasks.
Files
LobeHub Store Data Structures
How to structure data in Zustand stores for fast list rendering, multi-detail caching, and ergonomic optimistic updates.
Core Principles
✅ DO
1. Separate List and Detail — different structures for list pages and detail pages 2. Use Map for Details — cache multiple detail pages with Record<string, Detail> 3. Use Array for Lists — simple arrays for list display 4. Types from `@lobechat/types` — never use @lobechat/database types in stores 5. Distinguish List and Detail types — List types may have computed UI fields
❌ DON'T
1. Don't use a single detail object — can't cache multiple pages 2. Don't mix List and Detail types — they have different purposes 3. Don't use database types — use types from @lobechat/types 4. Don't use Map for lists — simple arrays are sufficient
---
Type Definitions
Each entity gets its own file under @lobechat/types/. Each file exports two types:
- Detail type — full entity, including heavy fields (rubrics, content, editor state, …)
- List item type — a subset that excludes heavy fields, may add computed UI fields (counts, timestamps formatted for display)
Important: the List type is a subset, not an extends of Detail. Extending pulls the heavy fields right back in.
See `references/types.md` for full worked examples (Benchmark, Document) and the heavy-field exclusion checklist.
---
When to Use Map vs Array
Use Map + Reducer — for Detail Data
✅ Detail page data caching — multiple detail pages cached simultaneously ✅ Optimistic updates — update UI before API responds ✅ Per-item loading states — track which items are being updated ✅ Multi-page navigation — user can switch between details without refetching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;Examples: benchmark detail pages, dataset detail pages, user profiles.
Use Simple Array — for List Data
✅ List display — lists, tables, cards ✅ Refresh as a whole — entire list refreshes together ✅ No per-item updates — no need to mutate individual rows in place ✅ Simple data flow — fewer moving parts
benchmarkList: AgentEvalBenchmarkListItem[];Examples: benchmark list, dataset list, user list.
---
State Structure Pattern
// src/store/eval/slices/benchmark/initialState.ts
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
export interface BenchmarkSliceState {
// List — simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// Detail — map for multi-entity caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
loadingBenchmarkDetailIds: string[]; // per-item loading
// Mutation states (drive form-level UI)
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}
export const benchmarkInitialState: BenchmarkSliceState = {
benchmarkList: [],
benchmarkListInit: false,
benchmarkDetailMap: {},
loadingBenchmarkDetailIds: [],
isCreatingBenchmark: false,
isUpdatingBenchmark: false,
isDeletingBenchmark: false,
};---
Reducer Pattern (for Detail Map)
When the Detail Map needs optimistic updates (i.e. the user edits a row and the UI should reflect it before the server confirms), wire a typed reducer instead of inlining set calls. This keeps mutations testable and the dispatch surface small.
See `references/reducer.md` for the full discriminated-union action types, theproduce-based reducer, and theinternal_dispatch*slice methods that connect them to Zustand.
---
Data Structure Comparison
❌ WRONG — Single Detail Object
interface BenchmarkSliceState {
benchmarkDetail: AgentEvalBenchmark | null;
isLoadingBenchmarkDetail: boolean;
}Problems:
- Can only cache one detail page at a time
- Switching between details forces refetch
- No optimistic updates
- No per-item loading states
✅ CORRECT — Separate List and Detail
interface BenchmarkSliceState {
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
loadingBenchmarkDetailIds: string[];
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}Benefits:
- Cache multiple detail pages
- Fast navigation between cached details
- Optimistic updates via reducer
- Per-item loading states
- Clear separation of concerns
---
Component Usage
Accessing List Data
const BenchmarkList = () => {
const benchmarks = useEvalStore((s) => s.benchmarkList);
const isInit = useEvalStore((s) => s.benchmarkListInit);
if (!isInit) return <Loading />;
return (
<div>
{benchmarks.map((b) => (
<BenchmarkCard key={b.id} name={b.name} testCaseCount={b.testCaseCount} />
))}
</div>
);
};Accessing Detail Data
const BenchmarkDetail = () => {
const { benchmarkId } = useParams<{ benchmarkId: string }>();
const benchmark = useEvalStore((s) =>
benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
);
const isLoading = useEvalStore((s) =>
benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
);
if (!benchmark) return <Loading />;
return (
<div>
<h1>{benchmark.name}</h1>
{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),
};
// In component
const benchmark = useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!));
const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(benchmarkId!));---
Decision Tree
Need to store data?
│
├─ Is it a LIST for display?
│ └─ ✅ Use simple array: `xxxList: XxxListItem[]`
│ - May include computed fields
│ - Refreshed as a whole
│ - No optimistic updates needed
│
└─ Is it DETAIL page data?
└─ ✅ Use Map: `xxxDetailMap: Record<string, Xxx>`
- Cache multiple details
- Support optimistic updates
- Per-item loading states
- Requires reducer for mutations---
Checklist
When designing store state structure:
- [ ] Organize types by entity in separate files (e.g.
benchmark.ts,agentEvalDataset.ts) - [ ] Create Detail type (full entity with all fields including heavy ones)
- [ ] Create ListItem type:
- [ ] Subset of Detail (exclude heavy fields)
- [ ] May include computed statistics for UI
- [ ] NOT
extendsDetail - [ ] Use array for list data:
xxxList: XxxListItem[] - [ ] Use Map for detail data:
xxxDetailMap: Record<string, Xxx> - [ ] Per-item loading:
loadingXxxDetailIds: string[] - [ ] Reducer for detail map if optimistic updates needed (see `references/reducer.md`)
- [ ] Internal dispatch and loading methods
- [ ] Selectors for clean access (optional but recommended)
- [ ] Document in comments which fields are excluded from List and why
---
Best Practices
1. File organization — one entity per file, not mixed 2. List is a subset — ListItem excludes heavy fields, does not extends Detail 3. Clear naming — xxxList for arrays, xxxDetailMap for maps 4. Consistent patterns — all detail maps follow the same shape 5. Type safety — never use any, always use proper types 6. Document exclusions — comment which fields are excluded and why 7. Selectors — encapsulate access patterns 8. Loading states — per-item for details, global for mutations 9. Immutability — use Immer in reducers
Common Mistakes to Avoid
❌ DON'T extend Detail in List:
// Wrong — pulls heavy fields back in
export interface BenchmarkListItem extends Benchmark {
testCaseCount?: number;
}✅ DO create separate subset:
export interface BenchmarkListItem {
id: string;
name: string;
// ... only necessary fields
testCaseCount?: number; // Computed
}❌ DON'T mix entities in one file:
// Wrong — all entities in agentEvalEntities.ts✅ DO separate by entity:
// Correct — separate files
// benchmark.ts
// agentEvalDataset.ts
// agentEvalRun.ts---
Related Skills
data-fetching-architecture— how to fetch and update this datazustand— general Zustand patterns
Reducer Pattern (for Detail Map)
Why Use a Reducer?
- Immutable updates — Immer makes immutability easy
- Type-safe actions — discriminated union of action types prevents typos
- Testable — pure function, easy to unit test
- Reusable — same reducer powers optimistic updates and server-data writes
Reducer Structure
// src/store/eval/slices/benchmark/reducer.ts
import { produce } from 'immer';
import type { AgentEvalBenchmark } from '@lobechat/types';
// Action types — discriminated union
type SetBenchmarkDetailAction = {
id: string;
type: 'setBenchmarkDetail';
value: AgentEvalBenchmark;
};
type UpdateBenchmarkDetailAction = {
id: string;
type: 'updateBenchmarkDetail';
value: Partial<AgentEvalBenchmark>;
};
type DeleteBenchmarkDetailAction = {
id: string;
type: 'deleteBenchmarkDetail';
};
export type BenchmarkDetailDispatch =
| SetBenchmarkDetailAction
| UpdateBenchmarkDetailAction
| DeleteBenchmarkDetailAction;
export const benchmarkDetailReducer = (
state: Record<string, AgentEvalBenchmark> = {},
payload: BenchmarkDetailDispatch,
): Record<string, AgentEvalBenchmark> => {
switch (payload.type) {
case 'setBenchmarkDetail': {
return produce(state, (draft) => {
draft[payload.id] = payload.value;
});
}
case 'updateBenchmarkDetail': {
return produce(state, (draft) => {
if (draft[payload.id]) {
draft[payload.id] = { ...draft[payload.id], ...payload.value };
}
});
}
case 'deleteBenchmarkDetail': {
return produce(state, (draft) => {
delete draft[payload.id];
});
}
default:
return state;
}
};Internal Dispatch Methods
The slice exposes two internal_* methods so the reducer and the loading state stay encapsulated behind a stable contract:
// In action.ts
export interface BenchmarkAction {
// ... other methods ...
// Internal — not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
// ... other methods ...
// Dispatch to reducer
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}`,
);
},
// Update loading state for a specific id
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => ({
loadingBenchmarkDetailIds: loading
? [...state.loadingBenchmarkDetailIds, id]
: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
}),
false,
'updateBenchmarkDetailLoading',
);
},
});The internal_ prefix is a convention — UI components should call the public mutation methods (e.g. updateBenchmark), which in turn call internal_dispatch*. This keeps reducer dispatch shapes out of the component layer.
Type Definitions in Detail
The skill body's Type Definitions section covers the rules; this file holds the full worked examples to keep SKILL.md lean.
Organization
Types should be organized by entity in separate files (not mixed):
@lobechat/types/src/eval/
├── benchmark.ts # Benchmark types
├── agentEvalDataset.ts # Dataset types
├── agentEvalRun.ts # Run types
└── index.ts # Re-exportsExample: Benchmark Types
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
/**
* Full benchmark entity with all fields including heavy data.
*/
export interface AgentEvalBenchmark {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
metadata?: Record<string, unknown> | null;
name: string;
referenceUrl?: string | null;
rubrics: EvalBenchmarkRubric[]; // Heavy field
updatedAt: Date;
}
/**
* Lightweight benchmark item — excludes heavy fields, may add computed stats.
*/
export interface AgentEvalBenchmarkListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
name: string;
// Note: rubrics NOT included (heavy field)
// Computed statistics for UI display
datasetCount?: number;
runCount?: number;
testCaseCount?: number;
}Example: Document Types (with heavy content)
// packages/types/src/document.ts
/**
* Full document entity — includes heavy content fields.
*/
export interface Document {
id: string;
title: string;
description?: string;
content: string; // Heavy field — full markdown content
editorData: any; // Heavy field — editor state
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight document item — excludes heavy content.
*/
export interface DocumentListItem {
id: string;
title: string;
description?: string;
// Note: content and editorData NOT included
createdAt: Date;
updatedAt: Date;
// Computed statistics
wordCount?: number;
lastEditedBy?: string;
}Heavy Fields to Exclude from List
- Large text content (
content,editorData,fullDescription) - Complex objects (
rubrics,config,metrics) - Binary data (
image,file) - Large arrays (
messages,items)
The reason these belong only on Detail: list pages render many rows, so pulling heavy fields blows up payload size and slows render. Detail pages render one entity, so the full payload is fine.