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 useEffect callbacks 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