-
Notifications
You must be signed in to change notification settings - Fork 19
Refactor: Replace event bridge pattern with Hybrid Action Hooks #49
Description
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:
- Polling anti-pattern: Checking every 10ms is wasteful and fragile
- No type safety:
CustomEvent<any>- TypeScript can't help - Debugging nightmare: Must trace flow across multiple files via global events
- Race conditions: Event listeners might not be registered when events fire
- Testing complexity: Can't easily test event chains
- Memory leaks: Easy to forget event cleanup
- Invisible coupling: No clear dependency relationship in code
Why This Pattern Exists
The architecture follows an "onion pattern":
- TanStack Query (outer) - server/filesystem data
- Zustand (middle) - client state
- 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)
- Create
hooks/editor/useEditorActions.ts - Implement
saveFilein hook with direct queryClient access - Update store to accept auto-save callback
- Wire in Layout component
- Update all call sites (keyboard shortcuts, buttons)
- 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)
createNewFile→ Move to hookdeleteFile→ Move to hook- Other orchestration actions
GATE: Pattern validated, team comfortable?
Phase 3: Clean Up (1-2 hours)
- Remove all event bridge infrastructure
- Update architecture guide with new pattern
- 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
anyin 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