12 KiB
Potential Solutions: EventDispatcher + React Hooks
This document outlines potential solutions to the EventDispatcher incompatibility with React hooks.
Solution 1: Fix EventDispatcher for React Compatibility
Overview
Modify EventDispatcher to be compatible with React's lifecycle and closure patterns.
Approach
-
Remove context object requirement for React:
- Add a new subscription method that doesn't require context matching
- Use WeakMap to track subscriptions by callback reference
- Auto-cleanup when callback is garbage collected
-
Stable callback references:
- Store callbacks with stable IDs
- Allow re-subscription with same ID to update callback
Implementation Sketch
class EventDispatcher {
private listeners: Map<string, Array<{ callback: Function; context?: any; id?: string }>>;
private nextId = 0;
// New React-friendly subscription
onReact(event: string, callback: Function): () => void {
const id = `react_${this.nextId++}`;
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push({ callback, id });
// Return cleanup function
return () => {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
this.listeners.set(
event,
eventListeners.filter((l) => l.id !== id)
);
};
}
// Existing methods remain for backward compatibility
on(event: string, callback: Function, context?: any) {
// ... existing implementation
}
}
Usage in React
useEffect(() => {
const cleanup = ProjectModel.instance.onReact('componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
return cleanup;
}, []);
Pros
- ✅ Minimal changes to existing code
- ✅ Backward compatible (doesn't break existing Views)
- ✅ Clean React-friendly API
- ✅ Automatic cleanup
Cons
- ❌ Doesn't explain WHY current implementation fails
- ❌ Adds complexity to EventDispatcher
- ❌ Maintains legacy pattern (not modern state management)
- ❌ Still have two different APIs (confusing)
Effort
Estimated: 4-8 hours
- 2 hours: Implement onReact method
- 2 hours: Test with existing components
- 2 hours: Update React components to use new API
- 2 hours: Documentation
Solution 2: React Bridge Wrapper
Overview
Create a React-specific hook that wraps EventDispatcher subscriptions.
Implementation
// hooks/useEventListener.ts
export function useEventListener<T = any>(
dispatcher: EventDispatcher,
eventName: string,
callback: (data?: T) => void
) {
const callbackRef = useRef(callback);
// Update ref on every render (avoid stale closures)
useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
// Wrapper that calls current ref
const wrapper = (data?: T) => {
callbackRef.current(data);
};
// Create stable context object
const context = { id: Math.random() };
dispatcher.on(eventName, wrapper, context);
return () => {
dispatcher.off(eventName, context);
};
}, [dispatcher, eventName]);
}
Usage
function ComponentsPanel() {
const [updateCounter, setUpdateCounter] = useState(0);
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
// ... rest of component
}
Pros
- ✅ Clean React API
- ✅ No changes to EventDispatcher
- ✅ Reusable across all React components
- ✅ Handles closure issues with useRef pattern
Cons
- ❌ Still uses legacy EventDispatcher internally
- ❌ Adds indirection
- ❌ Doesn't fix the root cause
Effort
Estimated: 2-4 hours
- 1 hour: Implement hook
- 1 hour: Test thoroughly
- 1 hour: Update existing React components
- 1 hour: Documentation
Solution 3: Migrate to Modern State Management
Overview
Replace EventDispatcher with a modern React state management solution.
Option 3A: React Context + useReducer
// contexts/ProjectContext.tsx
interface ProjectState {
components: Component[];
folders: Folder[];
version: number; // Increment on any change
}
const ProjectContext = createContext<{
state: ProjectState;
actions: {
renameComponent: (id: string, name: string) => void;
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
};
}>(null!);
export function ProjectProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(projectReducer, initialState);
const actions = useMemo(
() => ({
renameComponent: (id: string, name: string) => {
dispatch({ type: 'RENAME_COMPONENT', id, name });
ProjectModel.instance.renameComponent(id, name); // Sync with legacy
}
// ... other actions
}),
[]
);
return <ProjectContext.Provider value={{ state, actions }}>{children}</ProjectContext.Provider>;
}
export function useProject() {
return useContext(ProjectContext);
}
Option 3B: Zustand
// stores/projectStore.ts
import create from 'zustand';
interface ProjectStore {
components: Component[];
folders: Folder[];
renameComponent: (id: string, name: string) => void;
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
}
export const useProjectStore = create<ProjectStore>((set) => ({
components: [],
folders: [],
renameComponent: (id, name) => {
set((state) => ({
components: state.components.map((c) => (c.id === id ? { ...c, name } : c))
}));
ProjectModel.instance.renameComponent(id, name); // Sync with legacy
}
// ... other actions
}));
Option 3C: Redux Toolkit
// slices/projectSlice.ts
import { createSlice } from '@reduxjs/toolkit';
const projectSlice = createSlice({
name: 'project',
initialState: {
components: [],
folders: []
},
reducers: {
renameComponent: (state, action) => {
const component = state.components.find((c) => c.id === action.payload.id);
if (component) {
component.name = action.payload.name;
}
}
// ... other actions
}
});
export const { renameComponent } = projectSlice.actions;
Pros
- ✅ Modern, React-native solution
- ✅ Better developer experience
- ✅ Time travel debugging (Redux DevTools)
- ✅ Predictable state updates
- ✅ Scales well for complex state
Cons
- ❌ Major architectural change
- ❌ Need to sync with legacy ProjectModel
- ❌ High migration effort
- ❌ All React components need updating
- ❌ Risk of state inconsistencies during transition
Effort
Estimated: 2-4 weeks
- Week 1: Set up state management, create stores
- Week 1-2: Implement sync layer with legacy models
- Week 2-3: Migrate all React components
- Week 3-4: Testing and bug fixes
Solution 4: Proxy-based Reactive System
Overview
Create a reactive wrapper around ProjectModel that React can subscribe to.
Implementation
// utils/createReactiveModel.ts
import { useSyncExternalStore } from 'react';
export function createReactiveModel<T extends EventDispatcher>(model: T) {
const subscribers = new Set<() => void>();
let version = 0;
// Listen to ALL events from the model
const eventProxy = new Proxy(model, {
get(target, prop) {
const value = target[prop];
if (prop === 'notifyListeners') {
return (...args: any[]) => {
// Call original
value.apply(target, args);
// Notify React subscribers
version++;
subscribers.forEach((callback) => callback());
};
}
return value;
}
});
return {
model: eventProxy,
subscribe: (callback: () => void) => {
subscribers.add(callback);
return () => subscribers.delete(callback);
},
getSnapshot: () => version
};
}
// Usage hook
export function useModelChanges(reactiveModel: ReturnType<typeof createReactiveModel>) {
return useSyncExternalStore(reactiveModel.subscribe, reactiveModel.getSnapshot, reactiveModel.getSnapshot);
}
Usage
// Create reactive wrapper once
const reactiveProject = createReactiveModel(ProjectModel.instance);
// In component
function ComponentsPanel() {
const version = useModelChanges(reactiveProject);
const treeData = useMemo(() => {
return buildTree(reactiveProject.model);
}, [version]);
// ... rest of component
}
Pros
- ✅ Uses React 18's built-in external store API
- ✅ No changes to EventDispatcher or ProjectModel
- ✅ Automatic subscription management
- ✅ Works with any EventDispatcher-based model
Cons
- ❌ Proxy overhead
- ❌ All events trigger re-render (no granularity)
- ❌ Requires React 18+
- ❌ Complex debugging
Effort
Estimated: 1-2 days
- 4 hours: Implement reactive wrapper
- 4 hours: Test with multiple models
- 4 hours: Update React components
- 4 hours: Documentation and examples
Solution 5: Manual Callbacks (Current Workaround)
Overview
Continue using manual refresh callbacks as implemented in Task 004B.
Pattern
// Hook provides forceRefresh
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// Actions accept onSuccess callback
function performAction(data: any, onSuccess?: () => void) {
// ... do work ...
if (success && onSuccess) {
onSuccess();
}
}
// Component wires them together
performAction(data, () => {
forceRefresh();
});
Pros
- ✅ Already implemented and working
- ✅ Zero architectural changes
- ✅ Simple to understand
- ✅ Explicit control over refreshes
Cons
- ❌ Tech debt accumulates
- ❌ Easy to forget callback in new code paths
- ❌ Not scalable for complex event chains
- ❌ Loses reactive benefits
Effort
Estimated: Already done
- No additional work needed
- Just document the pattern
Recommendation
Short-term (0-1 month): Solution 2 - React Bridge Wrapper
Implement useEventListener hook to provide clean API for existing event subscriptions.
Why:
- Low effort, high value
- Fixes immediate problem
- Doesn't block future migrations
- Can coexist with manual callbacks
Medium-term (1-3 months): Solution 4 - Proxy-based Reactive System
Implement reactive model wrappers using useSyncExternalStore.
Why:
- Uses modern React patterns
- Minimal changes to existing code
- Works with legacy models
- Provides automatic reactivity
Long-term (3-6 months): Solution 3 - Modern State Management
Gradually migrate to Zustand or Redux Toolkit.
Why:
- Best developer experience
- Scales well
- Standard patterns
- Better tooling
Migration Path
- Phase 1 (Week 1-2):
- Implement
useEventListenerhook - Update ComponentsPanel to use it
- Document pattern
- Implement
- Phase 2 (Month 2):
- Implement reactive model system
- Test with multiple components
- Roll out gradually
- Phase 3 (Month 3-6):
- Choose state management library
- Create stores for major models
- Migrate components one by one
- Maintain backward compatibility
Decision Criteria
Choose solution based on:
- Timeline: How urgently do we need React components?
- Scope: How many Views are we migrating to React?
- Resources: How much dev time is available?
- Risk tolerance: Can we handle breaking changes?
- Long-term vision: Are we fully moving to React?
If migrating many Views: Invest in Solution 3 (state management)
If only a few React components: Use Solution 2 (bridge wrapper)
If unsure: Start with Solution 2, migrate to Solution 3 later
Questions to Answer
Before deciding on a solution:
- How many jQuery Views are planned to migrate to React?
- What's the timeline for full React migration?
- Are there performance concerns with current EventDispatcher?
- What state management libraries are already in the codebase?
- Is there team expertise with modern state management?
- What's the testing infrastructure like?
- Can we afford breaking changes during transition?
Next Actions
- ✅ Complete this investigation documentation
- ⬜ Present options to team
- ⬜ Decide on solution approach
- ⬜ Create implementation task
- ⬜ Test POC with ComponentsPanel
- ⬜ Roll out to other components