Skip to content

Refactor: Replace event bridge pattern with Hybrid Action Hooks #49

@dannysmith

Description

@dannysmith

Overview

Replace the current event bridge pattern (custom events + polling) with the Hybrid Action Hooks pattern to improve type safety, performance, and maintainability.

Priority: Post-1.0.0 architectural improvement
Status: Not causing bugs, but creates technical debt
Effort: ~1 day (~1.25 days with incremental gates)

Current Issues

The application uses a "Bridge Pattern" where Zustand stores dispatch global window custom events to trigger actions in React components that have access to TanStack Query data.

Technical Problems:

  1. Polling anti-pattern: Checking every 10ms is wasteful and fragile
  2. No type safety: CustomEvent<any> - TypeScript can't help
  3. Debugging nightmare: Must trace flow across multiple files via global events
  4. Race conditions: Event listeners might not be registered when events fire
  5. Testing complexity: Can't easily test event chains
  6. Memory leaks: Easy to forget event cleanup
  7. Invisible coupling: No clear dependency relationship in code

Why This Pattern Exists

The architecture follows an "onion pattern":

  1. TanStack Query (outer) - server/filesystem data
  2. Zustand (middle) - client state
  3. useState (inner) - local UI state

The middle layer (Zustand) is trying to reach outward to the outer layer (TanStack Query), which violates the dependency flow. The event bridge is a workaround.

Recommended Solution: Hybrid Action Hooks

Core Insight: Different action types have different architectural needs:

  • User-triggered actions (Save button, keyboard shortcuts) → Should live in hooks
  • State-triggered actions (Auto-save, dirty tracking) → Should live in stores

Architecture

Stores: State + state-triggered logic only

const useEditorStore = create<EditorState>((set, get) => ({
  editorContent: '',
  isDirty: false,
  autoSaveCallback: null,
  
  // Register callback from hook
  setAutoSaveCallback: callback => set({ autoSaveCallback: callback }),
  
  // State mutations trigger auto-save
  setEditorContent: content => {
    set({ editorContent: content, isDirty: true })
    get().scheduleAutoSave()
  },
  
  scheduleAutoSave: () => {
    const { autoSaveCallback } = get()
    if (autoSaveCallback) {
      setTimeout(() => void autoSaveCallback(), 2000)
    }
  },
}))

Hooks: User-triggered actions with access to both stores and queries

export function useEditorActions() {
  const queryClient = useQueryClient()
  
  const saveFile = useCallback(async (showToast = true) => {
    // Direct access to stores via getState()
    const { currentFile, frontmatter, editorContent } = useEditorStore.getState()
    
    // Direct access to query data - NO EVENTS!
    const collections = queryClient.getQueryData(queryKeys.collections(projectPath))
    const schemaFieldOrder = /* ... */
    
    await invoke('save_markdown_content', { /* ... */ })
    
    useEditorStore.getState().markAsSaved()
    await queryClient.invalidateQueries({ /* ... */ })
    if (showToast) toast.success('File saved')
  }, [queryClient])
  
  return { saveFile }
}

Layout: Wire everything together

export function Layout() {
  const { saveFile } = useEditorActions()
  
  // Register auto-save callback with store
  useEffect(() => {
    useEditorStore.getState().setAutoSaveCallback(() => saveFile(false))
  }, [saveFile])
}

Benefits

No polling - Synchronous data access
Type-safe - TypeScript enforces everything
Testable - Can test hooks and stores independently
No race conditions - Standard React lifecycle
No memory leaks - Standard cleanup patterns
Easy debugging - Clear call paths
Follows React patterns - Hook composition is idiomatic
Performance - Eliminates 10ms polling overhead (~100x faster)
65% less complexity than current approach

Migration Path

Phase 1: Extract saveFile to Hook (2-3 hours)

  1. Create hooks/editor/useEditorActions.ts
  2. Implement saveFile in hook with direct queryClient access
  3. Update store to accept auto-save callback
  4. Wire in Layout component
  5. Update all call sites (keyboard shortcuts, buttons)
  6. Remove event bridge code for schema-field-order

GATE: If any issues arise, stop and reassess

Phase 2: Apply Pattern to Other Actions (1-2 hours each)

  1. createNewFile → Move to hook
  2. deleteFile → Move to hook
  3. Other orchestration actions

GATE: Pattern validated, team comfortable?

Phase 3: Clean Up (1-2 hours)

  1. Remove all event bridge infrastructure
  2. Update architecture guide with new pattern
  3. Add tests for hooks and store interactions

Expert Consultation Findings

React Performance Architect

"The Hybrid Action Hooks approach is architecturally sound and represents the best long-term solution."

  • Follows React idioms (hook composition is canonical)
  • Clear data flow (easy to trace)
  • Type safety with full TypeScript inference
  • Testable (independent layer testing)
  • Performance winner (eliminates polling overhead)

Tauri v2 Expert

Alternative "Callback Registry" pattern recommended if:

  • Planning multi-window architecture
  • Want all business logic centralized in stores
  • Team has strong Tauri/Rust expertise

For this codebase: Hybrid Action Hooks is better fit due to React-heavy architecture and single-window app.

Current Locations

  • src/store/editorStore.ts:264-309 (save file + event bridge)
  • src/components/frontmatter/FrontmatterPanel.tsx:42-77 (event listener)
  • src/hooks/useLayoutEventListeners.ts:104,176-178 (create-new-file event)

Success Criteria

  • No polling loops anywhere in codebase
  • Full TypeScript type safety (no any in action flows)
  • Single-file traceability for each user action
  • All tests passing (unit + integration)
  • No performance regressions
  • Concurrent save guard prevents race conditions
  • Architecture guide updated with new pattern

Related Documentation

  • Full analysis: docs/reviews/event-bridge-refactor-analysis.md (572 lines)
  • Original review: docs/reviews/staff-engineer-review-2025-10-24.md
  • Concerns analysis in task document

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions