This document traces the evolution of communication between the frontend (LedFx-Frontend-v2) and the audio-visualizer module, loaded dynamically via react-dynamic-module.
Status: Standalone module, completely isolated
const module = useDynamicModule({
url: '/modules/yz-audio-visualiser.js',
scope: 'YzAudioVisualiser',
module: '.'
})
const { AudioVisualiser } = module.exports || {}
// Just render, no external control
<AudioVisualiser />Limitation: Frontend cannot control visualizer at all.
Status: Basic communication via React props
<AudioVisualiser
backendAudioData={audioData}
config={config}
onConfigChange={handleConfigChange}
/>Problem Discovered:
- Whole component re-renders when ANY prop changes
- Props drilling through multiple internal levels
- Cascading re-renders hurt performance
Verdict: Works, but doesn't scale.
Status: Imperative API layer with shared state
// Visualizer exposes API
window.visualiserApi = {
setVisual: (type) => setVisualType(type),
togglePlay: () => setIsPlaying(prev => !prev),
loadPreset: (idx) => butterchurnRef.current.loadPreset(idx),
getCurrentVisual: () => visualType
}
// Frontend calls methods
window.visualiserApi?.setVisual('butterchurn')
// State shared via localStorage
localStorage.setItem('visualiser-config', JSON.stringify(config))
// BUT: Polling required for state changes!
setInterval(() => {
const current = window.visualiserApi?.getCurrentVisual()
if (current !== lastKnown) updateUI()
}, 100)Pros:
- ✅ Frontend can trigger actions
- ✅ Shared state across instances
Cons:
- 🔴 Polling required (check every 100ms)
- 🔴 Desync possible (localStorage race conditions)
- 🔴 No type safety
- 🔴 Limited to predefined methods
Verdict: Amazing and working! But not optimal...
Status: Zustand store with manual subscriptions
// Visualizer migrates to Zustand internally
export { useStore } from './store'
// Frontend subscribes to changes
const { useStore } = module.exports
useEffect(() => {
const unsubscribe = useStore?.subscribe(
state => state.visualType,
(newType) => {
// React to changes!
}
)
return unsubscribe
}, [])Pros:
- ✅ No more polling! Reactive updates!
- ✅ Zustand handles state management
Cons:
- 🔴 Manual subscription/unsubscription management
- 🔴 More boilerplate code
Verdict: Getting close!
Status: Direct Zustand store sharing (current)
"Why communicate via window.api and have polling, if the Zustand store can be shared directly? No more polling, no more wait, no desync - just instant direct actions and results!"
Audio-Visualizer Exports:
// src/index.ts
export { useStore } from './store'
export type { IStore } from './store'
export type {
StoreVisualizerState,
StoreVisualizerActions,
// ... all slice types
} from './store'Frontend Integration:
// frontend/src/components/AudioVisualiser/AudioVisualiser.tsx
const module = useDynamicModule({
url: '/modules/yz-audio-visualiser.js',
scope: 'YzAudioVisualiser',
module: '.'
})
// Extract useStore from module
const { useStore } = module.exports || {}
// Alias as useVstore (convention)
const useVstore = useStore
// DIRECT ACCESS - Read state (reactive!)
const visualType = useVstore?.(state => state.visualType)
const isPlaying = useVstore?.(state => state.isPlaying)
// DIRECT ACCESS - Call actions (type-safe!)
const setVisualType = useVstore?.(state => state.setVisualType)
const togglePlay = useVstore?.(state => state.togglePlay)
// USE IT!
<Button onClick={() => setVisualType?.('butterchurn')}>
Switch to Butterchurn
</Button>
// NO NEED TO SUBSCRIBE/UNSUBSCRIBE!
// Zustand hooks provide reactivity automatically! ✨
// IMPERATIVE - Batch updates when needed
useVstore?.setState({
visualType: 'astrofox',
isPlaying: true
})- ✅ No polling - Reactivity built-in via Zustand hooks
- ✅ No desync - Shared store = single source of truth
- ✅ Type-safe - Full TypeScript autocomplete across modules
- ✅ Bi-directional - Frontend reads/writes, visualizer updates UI
- ✅ Selective re-renders - Only components using changed state re-render
- ✅ DevTools - Redux DevTools works across module boundary
- ✅ Persistence - State survives module reloads
- ✅ No API layer - Direct store access eliminates middleware
- ✅ No subscribe/unsubscribe - Zustand handles automatically
- ✅ No prop drilling - Internal components use hooks directly
Still exists for ref-dependent operations:
// Only for operations requiring component internals (refs)
window.visualiserApi = {
loadPreset: (index) => butterchurnRef.current?.loadPreset(index),
toggleFullscreen: () => fullscreenHandle.toggle(),
getVisualizerIds: () => getAllVisualizerTypes()
}
// But 90% of communication now uses direct store sharing!| Feature | Gen 1 | Gen 2 | Gen 3 | Gen 4 | Gen 5 |
|---|---|---|---|---|---|
| Method | None | Props | window.api | Zustand subscribe | useVstore 💖 |
| Control | ❌ | Limited | Good | Better | Perfect ✅ |
| Latency | N/A | 0ms | 0-100ms (poll) | 0ms | 0ms |
| Re-renders | N/A | Cascading 😱 | N/A | Selective | Selective ✅ |
| Type Safety | N/A | Partial | None | Partial | Full ✅ |
| Desync Risk | N/A | Medium | High (localStorage) | Low | Zero ✅ |
| Polling | N/A | No | Required 🔴 | No | No ✅ |
| Manual Sub/Unsub | N/A | N/A | N/A | Yes 🔴 | No (auto) ✅ |
| DevTools | N/A | No | No | Yes | Yes ✅ |
| Bi-directional | N/A | Limited | No | Yes | Perfect ✅ |
Each generation solved the problems of the previous:
- 🌱 No communication → Need basic control
- 📦 Props → Re-render hell, prop drilling
- 🌉 window.api → Polling overhead, desync
- 🎯 Subscribe → Manual subscription management
- 💖 useVstore → Automatic reactivity, perfect harmony!
Bonus: Internal prop drilling also eliminated by migrating to Zustand hooks internally.
- ARCHITECTURE.md - Overall architecture
- ZUSTAND_STORE.md - Store structure and usage