# Phase 28: VU Meter Optimization - Research **Researched:** 2026-03-03 **Domain:** React performance optimization for high-frequency UI updates **Confidence:** HIGH ## Summary This phase addresses the root cause of page freezes during long sessions: VU meter updates at 50-70 updates/second flowing through React state management, triggering expensive reconciliation cycles. The current implementation uses `useState` in `useVuHelpers.js` to store VU levels, causing React to re-render every time the native C++ client sends a VU update. The solution involves three complementary patterns: 1. **External VU Store** - Store VU data outside React using `useSyncExternalStore` (with shim for React 16 compatibility) 2. **requestAnimationFrame Batching** - Limit updates to 60fps to match display refresh rate 3. **Direct DOM Updates via Refs** - Bypass React reconciliation entirely for the visual meter updates **Primary recommendation:** Create a standalone VU store module that buffers incoming VU data, batches updates with requestAnimationFrame, and exposes a subscription API. VU meter components use refs to directly manipulate DOM elements (CSS classes/styles) rather than relying on React state for visual updates. ## Standard Stack The established libraries/tools for this domain: ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | use-sync-external-store | ^1.6.0 | useSyncExternalStore shim | Official React shim, works with React 16.8+ | | React refs (useRef) | built-in | Direct DOM access | Bypasses reconciliation for high-frequency updates | | requestAnimationFrame | browser API | Frame synchronization | Limits updates to 60fps, matches display refresh | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | lodash/throttle | existing | Fallback throttling | If rAF batching insufficient | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | External store | Canvas rendering | More complex, overkill for simple meter | | Direct DOM | CSS animations | Less control over frame-by-frame updates | | useSyncExternalStore | Zustand | Would add dependency, shim is simpler for this use case | **Installation:** ```bash cd jam-ui npm install use-sync-external-store ``` ## Architecture Patterns ### Recommended Project Structure ``` src/ ├── stores/ │ └── vuStore.js # External VU store (NEW) ├── hooks/ │ ├── useVuHelpers.js # MODIFY: Remove useState, use external store │ └── useVuStore.js # NEW: Hook wrapping external store subscription ├── components/ │ └── client/ │ └── SessionTrackVU.js # MODIFY: Use refs for DOM updates └── context/ └── VuContext.js # MODIFY: Simplified, provides store access ``` ### Pattern 1: External Store with requestAnimationFrame Batching **What:** A plain JavaScript module that holds VU state outside React, batches incoming updates, and notifies subscribers on each animation frame. **When to use:** When data arrives faster than React can efficiently render (50+ updates/sec). **Example:** ```javascript // Source: React official docs + best practices for high-frequency data // src/stores/vuStore.js let vuLevels = {}; // { mixerId: { level: 0, clipping: false, timestamp: 0 } } let listeners = new Set(); let pendingUpdates = {}; let rafId = null; function emitChange() { for (const listener of listeners) { listener(); } } function flushUpdates() { // Merge pending updates into vuLevels if (Object.keys(pendingUpdates).length > 0) { vuLevels = { ...vuLevels, ...pendingUpdates }; pendingUpdates = {}; emitChange(); } rafId = requestAnimationFrame(flushUpdates); } export const vuStore = { // Called by native client bridge at 50-70/sec updateLevel(mixerId, level, clipping) { pendingUpdates[mixerId] = { level, clipping, timestamp: performance.now() }; // Don't emit - rAF loop handles it }, removeLevel(mixerId) { delete pendingUpdates[mixerId]; if (vuLevels[mixerId]) { vuLevels = { ...vuLevels }; delete vuLevels[mixerId]; emitChange(); } }, getSnapshot() { return vuLevels; // Must return same reference if unchanged }, subscribe(listener) { listeners.add(listener); // Start rAF loop when first subscriber if (listeners.size === 1 && !rafId) { rafId = requestAnimationFrame(flushUpdates); } return () => { listeners.delete(listener); // Stop rAF loop when no subscribers if (listeners.size === 0 && rafId) { cancelAnimationFrame(rafId); rafId = null; } }; }, // For single meter subscription (more efficient) getLevelSnapshot(mixerId) { return vuLevels[mixerId] || null; } }; ``` ### Pattern 2: Direct DOM Updates with Refs **What:** VU meter components use refs to directly manipulate DOM elements, bypassing React reconciliation. **When to use:** For visual updates that happen more than 30 times per second. **Example:** ```javascript // Source: React performance best practices // Direct DOM manipulation for VU lights function VuMeterDirect({ mixerId, lightCount = 16 }) { const containerRef = useRef(null); const lightsRef = useRef([]); // Subscribe to just this mixer's data useEffect(() => { let rafId = null; const updateVisuals = () => { const data = vuStore.getLevelSnapshot(mixerId); if (!data || !containerRef.current) return; const { level, clipping } = data; const activeLights = Math.round(level * lightCount); // Direct DOM manipulation - no React involvement lightsRef.current.forEach((light, i) => { if (!light) return; const isActive = (lightCount - 1 - i) < activeLights; const positionFromBottom = lightCount - 1 - i; // Remove all state classes light.classList.remove('vu-bg-success', 'vu-bg-warning', 'vu-bg-danger', 'vu-bg-secondary'); if (isActive) { if (clipping) { light.classList.add('vu-bg-danger'); } else if (positionFromBottom >= Math.floor(lightCount * 0.75)) { light.classList.add('vu-bg-danger'); } else if (positionFromBottom >= Math.floor(lightCount * 0.5)) { light.classList.add('vu-bg-warning'); } else { light.classList.add('vu-bg-success'); } } else { light.classList.add('vu-bg-secondary'); } }); rafId = requestAnimationFrame(updateVisuals); }; rafId = requestAnimationFrame(updateVisuals); return () => cancelAnimationFrame(rafId); }, [mixerId, lightCount]); // Render once, never re-render for VU updates return (
{Array.from({ length: lightCount }).map((_, i) => (
lightsRef.current[i] = el} className="vu-bg-secondary" style={{ height: '10px', width: '25px', marginTop: '1px', borderRadius: '2px', border: '1px solid #eee' }} /> ))}
); } ``` ### Pattern 3: useSyncExternalStore Hook Wrapper **What:** A custom hook that safely subscribes React components to the external store. **When to use:** When components need to respond to VU changes (e.g., clipping indicators that need to trigger other UI). **Example:** ```javascript // Source: React official documentation for useSyncExternalStore // src/hooks/useVuStore.js import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { vuStore } from '../stores/vuStore'; // Subscribe to all VU levels (use sparingly - causes re-renders) export function useAllVuLevels() { return useSyncExternalStore( vuStore.subscribe, vuStore.getSnapshot ); } // Subscribe to single mixer - more efficient export function useVuLevel(mixerId) { const getSnapshot = useCallback(() => vuStore.getLevelSnapshot(mixerId), [mixerId]); return useSyncExternalStore( vuStore.subscribe, getSnapshot ); } ``` ### Anti-Patterns to Avoid - **useState for VU levels:** Creates new state on every update, triggers reconciliation - **Redux for VU data:** 50-70 actions/sec overwhelms Redux DevTools and middleware - **Context for high-frequency updates:** Each update re-renders all consumers - **Creating new objects in getSnapshot:** Must return same reference if data unchanged - **Multiple rAF loops:** Centralize animation handling, don't create per-component loops ## Don't Hand-Roll Problems that look simple but have existing solutions: | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | External store subscription | Custom subscription system | useSyncExternalStore shim | Handles concurrent rendering edge cases | | Frame rate limiting | setInterval/setTimeout | requestAnimationFrame | Syncs with display refresh, pauses when tab hidden | | React 16 compatibility | Custom useSyncExternalStore | use-sync-external-store npm | Official React team maintained | **Key insight:** The combination of external store + rAF batching + direct DOM refs is a well-established pattern for this exact problem (high-frequency visual updates). The useSyncExternalStore shim handles React's concurrent rendering complexities that are easy to get wrong with a custom implementation. ## Common Pitfalls ### Pitfall 1: getSnapshot Returns New Object Every Call **What goes wrong:** Infinite re-render loop or excessive renders **Why it happens:** useSyncExternalStore uses Object.is() comparison; new objects always differ **How to avoid:** Store immutable snapshots, return the stored reference directly **Warning signs:** React DevTools shows constant re-renders, console shows "maximum update depth exceeded" ### Pitfall 2: subscribe Function Recreated Every Render **What goes wrong:** Unnecessary unsubscribe/resubscribe on every render **Why it happens:** Defining subscribe inside component creates new function reference each render **How to avoid:** Define subscribe outside component or use useCallback with stable dependencies **Warning signs:** VU meters flicker or drop updates during other component updates ### Pitfall 3: Memory Leaks from Uncancelled Animation Frames **What goes wrong:** rAF callbacks continue after component unmount, accessing null refs **Why it happens:** Missing cleanup in useEffect, or cleanup doesn't cancel all pending frames **How to avoid:** Store rafId in ref, cancel in cleanup, check refs before accessing **Warning signs:** Console errors about "cannot read property of null" after leaving session ### Pitfall 4: Blocking Main Thread with DOM Reads/Writes **What goes wrong:** Layout thrashing causes jank even at 60fps **Why it happens:** Interleaving offsetWidth reads with style writes forces browser reflow **How to avoid:** Batch all reads, then all writes; use classList instead of style for class changes **Warning signs:** React DevTools Profiler shows long "Commit" times, browser DevTools shows forced reflow ### Pitfall 5: Not Handling Missing Mixers **What goes wrong:** VU meter crashes when mixer removed mid-session **Why it happens:** Native client may remove mixer before React component unmounts **How to avoid:** Always check if mixer/level exists before updating, handle null gracefully **Warning signs:** Console errors during session participant leave ## Code Examples Verified patterns from official sources: ### Bridge Callback Integration ```javascript // Source: Current codebase pattern + optimization // Modify handleBridgeCallback in useMixerStore.js import { vuStore } from '../stores/vuStore'; const handleBridgeCallback = useCallback((vuData) => { for (const vuInfo of vuData) { const eventName = vuInfo[0]; if (eventName === "vu") { const mixerId = vuInfo[1]; const mode = vuInfo[2]; const leftValue = vuInfo[3]; const leftClipping = vuInfo[4]; // Convert dB to 0.0-1.0 range const normalizedLevel = (leftValue + 80) / 80; // Create qualified ID matching current pattern const fqId = (mode ? 'M' : 'P') + mixerId; // Update external store instead of React state vuStore.updateLevel(fqId, normalizedLevel, leftClipping); } } }, []); ``` ### VU Context Simplified ```javascript // Source: Pattern from codebase + useSyncExternalStore best practices // Modify VuContext.js import React, { createContext, useContext } from 'react'; import { vuStore } from '../stores/vuStore'; const VuContext = createContext(); export const VuProvider = ({ children }) => { return ( {children} ); }; export const useVuContext = () => { const context = useContext(VuContext); if (!context) { throw new Error('useVuContext must be used within a VuProvider'); } return context; }; ``` ### SessionTrackVU Optimized ```javascript // Source: Current component + direct DOM pattern // Modify SessionTrackVU.js import React, { useEffect, useRef, useCallback, memo } from 'react'; import { vuStore } from '../../stores/vuStore'; const SessionTrackVU = memo(function SessionTrackVU({ lightCount = 16, orientation = 'vertical', lightWidth = 15, lightHeight = 10, mixers }) { const containerRef = useRef(null); const lightsRef = useRef([]); const mixerIdRef = useRef(null); const rafIdRef = useRef(null); // Update mixerId when mixers change useEffect(() => { const mixer = mixers?.vuMixer; mixerIdRef.current = mixer ? `${mixer.mode ? 'M' : 'P'}${mixer.id}` : null; return () => { // Cleanup VU state on unmount if (mixerIdRef.current) { vuStore.removeLevel(mixerIdRef.current); } }; }, [mixers]); // Animation loop for direct DOM updates useEffect(() => { const updateVisuals = () => { const mixerId = mixerIdRef.current; if (!mixerId) { rafIdRef.current = requestAnimationFrame(updateVisuals); return; } const data = vuStore.getLevelSnapshot(mixerId); const level = data?.level ?? 0; const clipping = data?.clipping ?? false; const activeLights = Math.round(level * lightCount); // Direct DOM manipulation for (let i = 0; i < lightCount; i++) { const light = lightsRef.current[i]; if (!light) continue; const positionFromBottom = lightCount - 1 - i; const isActive = positionFromBottom < activeLights; // Use classList for efficient class manipulation light.className = isActive ? (clipping || positionFromBottom >= lightCount * 0.75) ? 'vu-light vu-bg-danger' : (positionFromBottom >= lightCount * 0.5) ? 'vu-light vu-bg-warning' : 'vu-light vu-bg-success' : 'vu-light vu-bg-secondary'; } rafIdRef.current = requestAnimationFrame(updateVisuals); }; rafIdRef.current = requestAnimationFrame(updateVisuals); return () => { if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); } }; }, [lightCount]); // Render only once - never re-renders for VU updates return (
{Array.from({ length: lightCount }).map((_, i) => (
lightsRef.current[i] = el} className="vu-light vu-bg-secondary" style={{ height: `${lightHeight}px`, width: '25px', marginTop: '1px', borderRadius: '2px', border: '1px solid #eee' }} /> ))}
); }); export default SessionTrackVU; ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | useState for frequent updates | useSyncExternalStore + refs | React 18 (2022) | Eliminates reconciliation overhead | | setInterval for animation | requestAnimationFrame | Browser standard | Syncs with display, battery efficient | | Inline styles in render | classList manipulation | Performance best practice | Avoids style recalculation | | Context for high-freq data | External stores | React 18 concurrent | Prevents cascade re-renders | **Deprecated/outdated:** - ReactUpdates.injection.injectBatchingStrategy: This was a React internal that older batching solutions used. Not applicable to modern React. - componentWillReceiveProps for VU: Lifecycle methods are deprecated; use effects and refs instead. ## Open Questions Things that couldn't be fully resolved: 1. **Optimal rAF batching threshold** - What we know: 60fps is standard display refresh, native client sends 50-70/sec (150ms refresh rate) - What's unclear: Whether we should batch to exactly 60fps or adapt based on actual update frequency - Recommendation: Start with rAF (60fps cap), measure performance, adjust if needed 2. **React DevTools Profiler verification approach** - What we know: Requirement says "0 re-renders from VU updates" - What's unclear: Exact profiler setup to verify this claim - Recommendation: Document profiler steps in verification task, capture before/after screenshots 3. **Interaction with existing MixersContext re-renders** - What we know: MixersContext was identified as creating new references every render (investigation finding) - What's unclear: Whether VU optimization alone is sufficient or MixersContext needs parallel fixes - Recommendation: VU optimization addresses VU-specific re-renders; MixersContext is separate scope ## Sources ### Primary (HIGH confidence) - [React useSyncExternalStore official docs](https://react.dev/reference/react/useSyncExternalStore) - API, patterns, caveats - [use-sync-external-store npm (React team)](https://github.com/facebook/react/blob/main/packages/use-sync-external-store/package.json) - Shim compatibility: React 16.8+ - [MDN requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) - Browser API specification ### Secondary (MEDIUM confidence) - [Epic React - useSyncExternalStore Demystified](https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0) - Implementation patterns - [OpenReplay - requestAnimationFrame in React](https://blog.openreplay.com/use-requestanimationframe-in-react-for-smoothest-animations/) - Hook patterns and cleanup - [SitePoint - Streaming Backends & React](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/) - High-frequency data patterns ### Tertiary (LOW confidence) - WebSearch results for "React VU meter" - Confirmed general patterns but no authoritative 2026 sources ## Metadata **Confidence breakdown:** - Standard stack: HIGH - Official React shim documented with version compatibility - Architecture: HIGH - Patterns verified against React official docs - Pitfalls: MEDIUM - Based on documented useSyncExternalStore caveats + general React performance patterns **Research date:** 2026-03-03 **Valid until:** 2026-04-03 (30 days - stable patterns, no breaking changes expected)