| name | description |
|---|---|
v2-renderer |
Wire up Renderer-process consumption of v2 data via React hooks and services. Covers replacing Redux useAppSelector/dispatch with useQuery/useMutation (DataApi), usePreference (settings), and useCache (temporary data), with multi-window sync considerations. Use when migrating React components from Redux to the v2 data layer. |
Replace Redux useAppSelector / dispatch in React components with v2 hooks (useQuery, usePreference, useCache) that talk to Main-process services via IPC.
This skill enforces strict TDD (red-green-refactor). For every unit of work: (1) write ONE failing test (red), (2) write the minimum code to make it pass (green), (3) refactor while keeping tests green. Repeat. Run pnpm test:renderer to verify.
Related skills:
v2-migrator- Phase 1: Migrating legacy data into SQLitev2-data-api- Phase 2: Main-process services that expose datavercel-composition-patterns- Phase 3: Renderer ui/components pattern
Cherry Studio has multiple renderer windows (main app, mini window, selection toolbar). Each system handles cross-window sync differently:
| System | Sync Strategy | Notes |
|---|---|---|
| DataApiService | No auto-sync; fetch on demand | Each window fetches fresh data independently |
| PreferenceService | Auto-broadcasts to all windows | Main process is source of truth; optimistic updates with rollback |
| CacheService (shared) | Auto-broadcasts to all windows | Main maintains authoritative copy; new windows get init-sync |
| CacheService (persist) | Auto-broadcasts + localStorage | Survives restarts; Main-priority override on sync |
| CacheService (memory) | No sync (process-local) | Isolated per renderer process |
import { useAppSelector, useAppDispatch } from '@/store'
import { updateSettings } from '@/store/settings'
function SettingsPage() {
const theme = useAppSelector(state => state.settings.theme)
const dispatch = useAppDispatch()
const handleThemeChange = (value: string) => {
dispatch(updateSettings({ theme: value }))
}
}User settings -> usePreference:
import { usePreference } from '@data/hooks/usePreference'
function SettingsPage() {
const [theme, setTheme] = usePreference('app.theme.mode')
const handleThemeChange = (value: string) => setTheme(value)
}Business data -> useQuery/useMutation:
import { useQuery, useMutation } from '@data/hooks/useDataApi'
function TopicList() {
const { data: topics, isLoading } = useQuery('/topics')
const { trigger: createTopic } = useMutation('POST', '/topics', {
refresh: ['/topics']
})
}Temporary/UI state -> useCache:
import { useSharedCache } from '@data/hooks/useCache'
function Sidebar() {
const [collapsed, setCollapsed] = useSharedCache('ui.sidebar.collapsed', false)
}Import from @data/hooks/useDataApi.
// Basic list
const { data, isLoading, error, refetch } = useQuery('/topics')
// With query params
const { data } = useQuery('/messages', { query: { topicId, page: 1, limit: 20 } })
// Single resource
const { data: topic } = useQuery('/topics/abc123')
// Conditional fetching (null key = skip)
const { data } = useQuery(topicId ? `/topics/${topicId}/messages` : null)
// Polling
const { data } = useQuery('/topics', { refreshInterval: 5000 })// Create
const { trigger: create, isLoading } = useMutation('POST', '/topics', {
refresh: ['/topics'], // Auto-refresh these queries after success
onSuccess: (data) => toast.success('Created'),
})
await create({ body: { name: 'New Topic' } })
// Update (full replace)
const { trigger: replace } = useMutation('PUT', `/topics/${id}`)
await replace({ body: { name: 'Updated', description: '...' } })
// Partial update
const { trigger: update } = useMutation('PATCH', `/topics/${id}`)
await update({ body: { name: 'New Name' } })
// Delete
const { trigger: remove } = useMutation('DELETE', `/topics/${id}`, {
refresh: ['/topics']
})
await remove()
// Optimistic update (instant UI, auto-rollback on failure)
const { trigger: toggleStar } = useMutation('PATCH', `/topics/${id}`, {
optimisticData: { ...topic, starred: !topic.starred }
})const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', {
query: { topicId },
limit: 20
})
// items: all loaded items flattened
// loadNext(): load next pageconst { items, page, total, hasNext, hasPrev, nextPage, prevPage } =
usePaginatedQuery('/topics', { limit: 10 })import { dataApiService } from '@data/DataApiService'
const topics = await dataApiService.get('/topics')
const topic = await dataApiService.get('/topics/abc123')
const newTopic = await dataApiService.post('/topics', { body: { name: 'New' } })
await dataApiService.patch('/topics/abc123', { body: { name: 'Updated' } })
await dataApiService.delete('/topics/abc123')// In hooks
const { data, error } = useQuery('/topics')
if (error?.code === ErrorCode.NOT_FOUND) return <NotFound />
// In try-catch
import { DataApiError, ErrorCode } from '@shared/data/api'
try {
await dataApiService.post('/topics', { body: data })
} catch (error) {
if (error instanceof DataApiError) {
if (error.code === ErrorCode.VALIDATION_ERROR) {
const fieldErrors = error.details?.fieldErrors
}
if (error.isRetryable) { /* safe to retry */ }
}
}Import from @data/hooks/usePreference.
// Optimistic (default) - UI updates immediately, syncs to DB
const [theme, setTheme] = usePreference('app.theme.mode')
await setTheme('dark')
// Pessimistic - waits for DB confirmation before UI update
const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false })
await setApiKey('sk-...')When to use which:
- Optimistic (default): frequent, non-critical changes (theme, font size)
- Pessimistic: security-sensitive or external-service settings (API keys)
const { theme, language, fontSize } = usePreferences([
'app.theme.mode',
'app.language',
'chat.message.font_size'
])import { preferenceService } from '@data/PreferenceService'
// Read
const theme = await preferenceService.get('app.theme.mode')
const settings = await preferenceService.getMultiple(['app.theme.mode', 'app.language'])
// Write
await preferenceService.set('app.theme.mode', 'dark')
await preferenceService.setMultiple({ 'app.theme.mode': 'dark', 'app.language': 'en' })
// Subscribe (useful in services, not components)
const unsub = preferenceService.subscribe('app.theme.mode', (newValue) => {
// Called when preference changes in any window
})
unsub() // cleanupImport from @data/hooks/useCache.
| Tier | Hook | Scope | Survives Restart | Cross-Window Sync | Use When |
|---|---|---|---|---|---|
| Memory | useCache |
Single renderer process | No | No | Computed results, search results, scroll positions — data local to one window that can be recomputed |
| Shared | useSharedCache |
All renderer windows | No | Yes (via Main) | UI state that must stay in sync across windows (sidebar collapsed, active panel, selection state) |
| Persist | usePersistCache |
All renderer windows | Yes (localStorage) | Yes (via Main) | User-specific ephemeral data worth keeping across restarts but not critical enough for Preference (recent files, last-used filters, draft text) |
Decision flow:
- Does this state need to survive app restart? →
usePersistCache - Does this state need to sync across windows? →
useSharedCache - Otherwise →
useCache(memory-only, cheapest)
// Memory cache - lost on restart, single-window only
const [results, setResults] = useCache('search.results', [])
const [results, setResults] = useCache('search.results', [], { ttl: 30000 }) // with TTL
// Shared cache - cross-window sync via Main, lost on restart
const [collapsed, setCollapsed] = useSharedCache('ui.sidebar.collapsed', false)
// Persist cache - cross-window sync + survives restart via localStorage
const [recent, setRecent] = usePersistCache('app.recent_files', [])// Type-safe (schema key) - auto-completion, compile-time validation
const [counter, setCounter] = useCache('ui.counter', 0)
// Template key (dynamic pattern, auto type inference)
const [scrollPos, setScrollPos] = useCache('scroll.position.topic123') // inferred: number
// Casual (fully dynamic, manual type)
cacheService.setCasual<TopicCache>(`topic:${id}`, data)
const topic = cacheService.getCasual<TopicCache>(`topic:${id}`)import { cacheService } from '@data/CacheService'
// Memory
cacheService.set('search.results', data)
cacheService.set('search.results', data, 30000) // with TTL
const data = cacheService.get('search.results')
cacheService.delete('search.results')
// Shared
cacheService.setShared('window.layout', config)
const layout = cacheService.getShared('window.layout')
// Persist
cacheService.setPersist('app.recent_files', files)
const files = cacheService.getPersist('app.recent_files')// SharedCache syncs from Main on window init (async)
if (cacheService.isSharedCacheReady()) { /* synced */ }
const unsub = cacheService.onSharedCacheReady(() => {
// Called immediately if already ready, or when sync completes
})
// getShared() returns undefined before ready
// setShared() works immediately (broadcasts to Main)
// Hooks work normally - update when sync completesFollow the red-green-refactor cycle for every component migration. For each piece of UI:
- Red: Write a failing test for the new hook/component behavior
- Green: Write the minimum code to make it pass (replace Redux with v2 hook)
- Refactor: Clean up while keeping tests green
Use the unified mocks in tests/__mocks__/renderer/.
Mocks are globally configured in tests/renderer.setup.ts. Import mock utilities via @test-mocks/*:
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
import { MockDataApiUtils } from '@test-mocks/renderer/DataApiService'
import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi'import { describe, expect, it, beforeEach } from 'vitest'
import { useQuery, useMutation } from '@data/hooks/useDataApi'
import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi'
describe('TopicList', () => {
beforeEach(() => MockUseDataApiUtils.resetMocks())
it('should fetch topics via useQuery', () => {
MockUseDataApiUtils.mockQueryData('/topics', { items: [{ id: '1', name: 'Test' }] })
const { data } = useQuery('/topics')
expect(data.items).toHaveLength(1)
})
it('should handle loading state', () => {
const { loading } = useQuery('/topics')
expect(loading).toBe(false) // mock returns immediately
})
it('should create topic via useMutation', async () => {
const { mutate } = useMutation('POST', '/topics')
const result = await mutate({ body: { name: 'New' } })
expect(result.created).toBe(true)
})
it('should handle API errors', async () => {
MockDataApiUtils.setErrorResponse('/topics', 'GET', new Error('Network error'))
// Test error handling in component
})
})import { usePreference } from '@data/hooks/usePreference'
it('should read and update preference', async () => {
const [theme, setTheme] = usePreference('app.theme.mode')
expect(theme).toBeDefined()
await setTheme('dark')
})import { useCache } from '@data/hooks/useCache'
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
beforeEach(() => MockCacheUtils.resetMocks())
it('should use cache with initial value', () => {
const [value, setValue] = useCache('search.results', [])
expect(value).toEqual([])
})
it('should pre-populate cache for testing', () => {
MockCacheUtils.setInitialState({
memory: [['search.results', [{ id: '1' }]]],
})
const [value] = useCache('search.results', [])
expect(value).toHaveLength(1)
})- Component renders correctly with data from hooks
- Loading and error states display properly
- User interactions trigger correct mutations/updates
- Multi-window behavior: shared cache syncs, local cache doesn't
- Old Redux imports are removed (no
useAppSelector/dispatch)
// Before: Redux
const theme = useAppSelector(s => s.settings.theme)
dispatch(updateSettings({ theme: 'dark' }))
// After: Preference
const [theme, setTheme] = usePreference('app.theme.mode')
await setTheme('dark')// Before: Redux + Dexie
const topics = useAppSelector(s => s.topics.items)
dispatch(addTopic(data))
// After: DataApi
const { data: topics, isLoading } = useQuery('/topics')
const { trigger: addTopic } = useMutation('POST', '/topics', { refresh: ['/topics'] })
await addTopic({ body: data })// Before: Redux
const collapsed = useAppSelector(s => s.runtime.sidebarCollapsed)
dispatch(setSidebarCollapsed(true))
// After: SharedCache (cross-window) or local state
const [collapsed, setCollapsed] = useSharedCache('ui.sidebar.collapsed', false)// Before: Redux selector
const stats = useAppSelector(selectTopicStats)
// After: useQuery (computed on server) or useCache (computed on client)
const { data: stats } = useQuery('/topics/stats')
// or
const [stats, setStats] = useCache('topics.stats', null)// Before: Redux
const showTimestamp = useAppSelector(s => s.settings.showMessageTimestamp)
// After: Preference
const [showTimestamp] = usePreference('chat.display.show_timestamp')When a feature uses the Layered Preset pattern (see v2-data-api skill), the renderer merges presets with user overrides at read time.
import { PRESETS_MY_FEATURE, type MyFeaturePreset } from '@shared/data/presets/my-feature'
import { usePreference } from '@data/hooks/usePreference'
function useMyFeatureConfig(presetId: string): MyFeaturePreset {
const [overrides] = usePreference('feature.my_feature.overrides')
const preset = PRESETS_MY_FEATURE.find(p => p.id === presetId)!
return { ...preset, ...overrides[presetId] }
}Write only the changed fields — keep the delta minimal:
function useMyFeatureOverride(presetId: string) {
const [overrides, setOverrides] = usePreference('feature.my_feature.overrides')
const updateOverride = async (patch: Partial<MyFeatureOverride>) => {
const current = overrides[presetId] ?? {}
await setOverrides({ ...overrides, [presetId]: { ...current, ...patch } })
}
const resetOverride = async () => {
const { [presetId]: _, ...rest } = overrides
await setOverrides(rest)
}
return { override: overrides[presetId] ?? {}, updateOverride, resetOverride }
}// Before (Redux): separate maps per field
const selectedModels = useAppSelector(s => s.codeTools.selectedModels) // Record<toolId, Model>
const envVars = useAppSelector(s => s.codeTools.environmentVariables) // Record<toolId, string>
// After (v2): single overrides preference + preset merge
const [overrides] = usePreference('feature.code_tools.overrides')
const effectiveConfig = { ...PRESETS_CODE_TOOLS.find(p => p.id === toolId)!, ...overrides[toolId] }See packages/shared/data/presets/code-tools.ts for a reference implementation.
- Add to
packages/shared/data/cache/cacheSchemas.ts:export type UseCacheSchema = { 'myFeature.data': MyDataType } export const DefaultUseCache = { 'myFeature.data': { items: [], lastUpdated: 0 } }
- Template key for dynamic patterns:
'scroll.position.${topicId}': number // matches scroll.position.topic123
See v2-data-api skill, "Adding a Preference Key" section. Note that preferenceSchemas.ts is auto-generated by the v2-refactor-temp/tools/data-classify toolchain — for simple keys, use the toolchain (update classification.json or target-key-definitions.json, then run npm run generate) instead of editing the generated file directly.
- Component/hook tests written and failing (red) with mocked services (
@test-mocks/renderer/*) - Minimum hook/component code written to make tests pass (green)
- Loading and error state tests added (red), then handled (green)
- User interaction tests added (red): mutations, preference updates
- Code refactored with all tests still passing
- Mock utilities reset in
beforeEach - Tests pass:
pnpm test:renderer
- Identified correct system for each piece of data (DataApi vs Preference vs Cache)
- Old
useAppSelector/dispatchcalls removed - New hooks wired up with proper types
- Loading states handled (
isLoadingfor DataApi) - Error states handled (DataApi error codes)
- Mutation callbacks set up (
refresh,onSuccess) - Multi-window behavior verified (shared data syncs, local data doesn't)
- Optimistic vs pessimistic update strategy chosen for preferences
- Cache tier chosen correctly (memory vs shared vs persist)
- New cache/preference keys added to schemas
- Layered Preset merge logic implemented (if feature uses presets + overrides)
- All tests pass:
pnpm test -
pnpm lint && pnpm formatpass -
pnpm build:checkpasses
docs/en/references/data/README.md- System selection guidedocs/en/references/data/data-api-in-renderer.md- DataApi hooks and patternsdocs/en/references/data/preference-usage.md- Preference hooks and servicedocs/en/references/data/cache-overview.md- Cache architecturedocs/en/references/data/cache-usage.md- Cache hooks and patterns