The Great Store Migration ποΈ
From Hook Spaghetti to a Single Source of Truth
The Problem: Death by a Thousand Hooks
Before this change, our data layer was⦠creative:
- Same object living in multiple cache entries, looking different in each (cache drift)
- Hooks with 15 parameters and
enabled: !!captureId && !!threadId && userHasLoggedInOnAFullMoon - Business logic scattered across
useEffectcallbacks like confetti - Debugging required spelunking through React DevTools, hoping to find the right component
The Solution: One Store to Rule Them All
A centralized, TanStack Query-powered store with:
- Single explicit source of truth for everything data-related (but not really just it)
- Modularity through slices β no God Module here
- Imperative methods for when hooks are overkill
- Events! Because sometimes you just need to broadcast things
- Foundation for logic extraction β business logic belongs in the store, not scattered across components
Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Store β
β βββββββββββββββββββ βββββββββββββββββββββββββββββββ β
β β QueryClient β β EventEmitter β β
β βββββββββββββββββββ βββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β CapturesSliceβ βArtifactsSliceβ β AISlice β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β
β ββββββββββββββββ ββββββββββββββββ β
β β ActiveSlice β βChatThreads...β β
β ββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
PR #1743: The Foundation
Introduces Store, BaseSlice, and CapturesSlice.
export const store = new Store()
if (isDev) {
;(window as any).store = store
}
Yes, you can now type store.captures.get('some-id') directly in DevTools.
You're welcome.
The Store: Grand Central Station
export class Store {
public queryClient: QueryClient
public events: EventEmitter
public captures: CapturesSlice
public artifacts: ArtifactsSlice
public ai: AISlice
public active: ActiveSlice
constructor() {
this.events = new EventEmitter()
this.queryClient = new QueryClient({
/* β¦ */
})
this.captures = new CapturesSlice(this)
this.artifacts = new ArtifactsSlice(this)
// β¦
}
}
BaseSlice: The Foundation
Every slice extends BaseSlice:
abstract class BaseSlice {
protected store: Store
protected abstract get keys(): { all: readonly string[] }
protected createKey<T, S>(namespace: T, scope: S, params?: any) {
return params ? [namespace, scope, params] : [namespace, scope]
}
public getCachedData(filter?: Record<string, unknown>) {
// Returns all cached data for this slice
// Optionally filtered by matching properties
}
}
Access Pattern #1: Subscription Hooks
For views that need to stay in sync with data.
function CaptureDashboard() {
const { captures, hasNextPage, fetchNextPage } = store.captures.useInfiniteList({ perPage: 20 })
return (
<div>
{captures.map(item => (
<CaptureCard key={item.id} data={item} />
))}
</div>
)
}
Access Pattern #2: Entity Hooks
Bundles data + actions for one specific entity. Perfect for detail views.
function CaptureInspector({ id }: { id: string }) {
const { capture, update, delete: remove, isUpdating } = store.captures.useCapture(id)
return (
<aside>
<h2>{capture?.name}</h2>
<button onClick={() => update({ status: 'completed' })}>
{isUpdating ? 'Savingβ¦' : 'Mark Done'}
</button>
<button onClick={remove}>Delete</button>
</aside>
)
}
Access Pattern #3: Imperative Methods
For when you're not in React-land. Or when hooks are just⦠too much.
// In an event handler, utility function, or DevTools console
const capture = await store.captures.get('abc-123')
// Hover-to-prefetch
onMouseEnter={() => store.captures.prefetch(item.id)}
// After websocket notification
store.captures.refetch('abc-123')
PR #1754: Migrate Capture Hooks
Out with the old:
const { capture, isLoading } = useCapture(id)
const invalidateCaptures = useInvalidateCaptures()
const { refetchCapture } = useRefetchCapture()
In with the new:
const { data: capture, isLoading } = store.captures.useFind(id)
store.captures.refetchAll()
store.captures.refetch(captureId)
PR #1761: Migrate Artifact Hooks
// Before: Hook that returned a function π
const { updateArtifact } = useUpdateArtifact(artifact.id)
updateArtifact({ title: newTitle })
// After: Just call the method
store.artifacts.update(artifact.id, { title: newTitle })
Prefer imperative update() over useUpdate() when you don't need mutation state.
PR #1762: The AI Slice
Centralizes all AI-related state: streaming, query status, artifacts in flight.
// Before
const { streamingArtifactIds } = useQueryInfo(threadId, captureId)
const isStreaming = streamingArtifactIds.includes(artifactId)
// After β synchronous, no hooks needed
const isStreaming = store.ai.isArtifactStreaming(artifactId)
AI Events: Real-Time Updates
Subscribe to streaming content from anywhere:
store.events.on(`ai:text:${threadId}`, text => {
// text: accumulated assistant response
})
store.events.on(`ai:text:${artifactId}`, content => {
// content: accumulated artifact content (e.g., meeting summary)
})
Text
But also other helpers too:
// Fires off a query
store.ai.performQuery('β¦')
// Checks if an artifact is streaming
store.ai.isArtifactStreaming(artifactId)
PR #1763: ActiveSlice for Debugging
Ever needed to know the capture that youβre looking at while debugging?
Before: console.log and pain.
After:
// In browser console
store.active.capture
// => <currently active capture>
store.active.thread
// => <currently active thread>
PR #1765: Migrate Chat Thread Hooks
The Great Unflattening
Before: Same thread stored in multiple places, drifting apart like old friends:
['captures', captureId, 'chat_threads', threadId] // capture-scoped
['chatThreads', 'detail', threadId] // global
After: One key to find them:
['chatThreads', 'detail', threadId]
No more cache drift. No more "why does this look different in that component?"
Chat Thread Message Helpers
Because building chat UIs should be fun (or at least bearable):
// Before
const { addUserMessage, updateLastAssistantMessage } = useUpdateChatMessagesInCache()
addUserMessage(text, { threadId, captureId })
// After
store.chatThreads.addUserMessage(text, { threadId, captureId })
store.chatThreads.updateLastAssistantMessage({ content, threadId, captureId })
Debugging? Just call store.chatThreads.addUserMessage() from DevTools to see how a container looks with several messages.
Cross-Slice Communication
Slices can talk to each other. Business logic lives in the data layer, not in your components.
// Inside CapturesSlice
async create(data: Partial<Capture>) {
const user = this.store.auth.getCurrentUser()
if (!user.isPro && user.count >= 5) {
throw new Error("Free tier limit. Time to pay up.")
}
const result = await createCapture({ /* β¦ */ })
// Tell other slices what happened
this.store.analytics.invalidateDashboard()
return result
}
Global Error Handling
All failures broadcast via events. Not yet plugged in, but handy.
function GlobalErrorListener() {
useEffect(() => {
const unsubscribe = store.events.on('error', payload => {
toast.error(`Error: ${payload.message}`)
if (payload.error.status === 401) {
store.clearAll()
window.location.href = '/login'
}
})
return unsubscribe
}, [])
return null
}
Debugging: getCachedData()
Inspect the entire cache state for any slice:
store.captures.getCachedData()
// => {
// 'captures:detail:abc-123': { id: 'abc-123', name: 'My Capture' },
// 'captures:list:infinite:{"perPage":100}': { pages: [...] }
// }
// Filter by properties (deep search!)
store.captures.getCachedData({ status: 'pending' })
Key Principles Going Forward
1. Data logic lives in the store
Complex tasks β simple functions β fire with effects where needed.
// β
Testable function, hook just fires it
useCreateEmptyMeetingSummary()
// β Open effect with embedded logic
useEffect(() => {
if (someCondition && anotherCondition && β¦) {
// 47 lines of logic
}
}, [everything, under, the, sun, causing, re, renders])
Key Principles (cont.)
2. No more "fat" hooks
Hooks are bridges between UI and data state. Not the entire bridge, highway, and destination.
// β
Hook accepts simple options, reads from store
function useDoThing(options: { enabled?: boolean }) {
const data = store.something.useData()
// β¦
}
// β Hook accepts data AND options AND the kitchen sink
function useDoThing(
data: SomeType,
moreData: AnotherType,
options: { enabled?: boolean; format?: string; locale?: string },
) {
/* β¦ */
}
Key Principles (cont.)
3. Avoid "naked useEffects"
a.k.a. complex effects that are hard to debug, understand, and keep in sync.
// β The Old Wayβ’
useEffect(() => {
if (capture && !artifact && threadId && user.isPro && phase === 'recording') {
// Nested conditions, API calls, state updatesβ¦
}
}, [capture, artifact, threadId, user, phase /* more */])
// β
The Store Wayβ’
useEffect(() => {
if (store.captures.shouldCreateSummaryFor(captureId)) {
store.artifacts.createEmptySummary(captureId)
}
}, [captureId])
Testability: The Real Win
Business logic in stores = unit tests without mounting components.
// Testing store logic directly β no React, no mocking providers
describe('CapturesSlice', () => {
it('throws when free tier user exceeds limit', async () => {
store.auth.getCurrentUser = () => ({ isPro: false, count: 5 })
await expect(store.captures.create({ name: 'One too many' })).rejects.toThrow('Free tier limit')
})
it('allows Pro users to create unlimited captures', async () => {
store.auth.getCurrentUser = () => ({ isPro: true, count: 999 })
await expect(store.captures.create({ name: 'No limits' })).resolves.toBeDefined()
})
})
Hooks? Test that they call the right store method. That's it.
Migration Cheat Sheet
| Old Pattern | New Pattern |
|---|---|
useCapture(id) |
store.captures.useFind(id) |
useInvalidateCaptures() |
store.captures.refetchAll() |
useUpdateArtifact(id) |
store.artifacts.update(id, data) |
useQueryInfo(threadId, captureId) |
store.ai.useFindQuery(threadId, captureId) |
useChatThread(threadId, captureId) |
store.chatThreads.useFind(threadId, captureId) |
DevTools Quick Reference
// Current state
store.active.capture
store.active.thread
// Fetch data
await store.captures.get('id')
await store.artifacts.get('id')
// Cache inspection
store.captures.getCachedData()
store.captures.getCachedData({ status: 'recording' })
// Cache manipulation (for testing)
store.chatThreads.addUserMessage('Hello!', { threadId: 'xxx' })
// Check streaming state
store.ai.isArtifactStreaming('artifact-id')
store.ai.getStreamingArtifactIds()
Summary
| PR | What Changed |
|---|---|
| #1743 | Initial store architecture, CapturesSlice |
| #1754 | Migrated capture hooks |
| #1761 | ArtifactsSlice, migrated artifact hooks |
| #1762 | AISlice, centralized AI state |
| #1763 | ActiveSlice for debugging URL vars |
| #1765 | ChatThreadsSlice, flattened cache keys |
What We Gained
- Explicit source of truth: One place for data, one way to access it
- Developer experience: Console access, cache inspection, simplified debugging
- Testability: Logic in functions, not in 47-line useEffects
- Maintainability: Clear boundaries, predictable patterns
- Sanity: Yours, mostly
Questions?
store.questions.submit()
(Not actually implemented. Yet.)
The Great Store Migration ποΈ
By Julio Ody
The Great Store Migration ποΈ
- 26