docs(32): research state update optimization phase domain

Phase 32: State Update Optimization
- Debounce patterns for track sync consolidation documented
- Ref-based closure pattern for stable debounce identified
- State colocation principles for button loading states
- Content comparison before Redux dispatch patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-03-05 19:22:50 +05:30
parent af9b467546
commit ae583e7bc7
1 changed files with 442 additions and 0 deletions

View File

@ -0,0 +1,442 @@
# Phase 32: State Update Optimization - Research
**Researched:** 2026-03-05
**Domain:** React state update optimization, debouncing patterns, state colocation
**Confidence:** HIGH
## Summary
Phase 32 addresses three distinct but related performance problems in JKSessionScreen: (1) triple track sync API calls on session join, (2) debounce function recreation due to dependency array changes in useSessionModel, and (3) redundant Redux dispatches when mixer categorization content is unchanged. These issues cause unnecessary network traffic, wasted CPU cycles, and excessive component re-renders.
The solutions leverage standard React patterns already established in the codebase:
1. **Consolidate track sync calls** into a single debounced invocation with appropriate delay
2. **Fix debounce recreation** using useRef closure pattern (already documented in developerway.com article)
3. **Add content comparison** before dispatching mixer category updates
4. **Move loading states** (resyncLoading, videoLoading) to child components per state colocation principles
These are targeted fixes to specific hotspots identified in the investigation phase. Prior phases (28-31) addressed broader performance concerns; this phase eliminates remaining redundant work.
**Primary recommendation:** Replace the 3-timer track sync pattern with a single debounced call, fix useSessionModel trackChanges debounce using useRef closure pattern, and move resyncLoading/videoLoading state from JKSessionScreen to ResyncButton and VideoButton components respectively.
## Standard Stack
The established libraries/tools for this domain:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| React useState | 16.13.1 (built-in) | Local component state | For colocated button loading states |
| React useRef | 16.13.1 (built-in) | Stable references for closures | Fix debounce stale closure |
| React useMemo | 16.13.1 (built-in) | Memoize debounced functions | Prevent recreation |
| lodash/debounce | 4.x (installed) | Debounce implementation | Already used in useSessionModel |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @reduxjs/toolkit | 1.6.1 (installed) | Redux state management | Mixer categorization dispatches |
| react-redux shallowEqual | 7.x (installed) | Array comparison | Compare mixer arrays before dispatch |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| lodash debounce | use-debounce hook | Additional dependency; lodash already installed and used |
| useRef closure pattern | React 18 useEvent | Not available in React 16.13.1 |
| Manual array comparison | Lodash isEqual | isEqual is deep; arrays of objects need custom comparison |
**Installation:**
```bash
# No new dependencies required
# All tools are React built-ins or already installed
```
## Architecture Patterns
### Recommended Project Structure
```
src/
├── components/
│ └── client/
│ ├── JKSessionScreen.js # MODIFY: Remove videoLoading, resyncLoading
│ ├── JKResyncButton.js # CREATE: Self-contained with loading state
│ └── JKVideoButton.js # CREATE: Self-contained with loading state
├── hooks/
│ ├── useSessionModel.js # MODIFY: Fix trackChanges debounce
│ └── useMixerHelper.js # MODIFY: Add content comparison before dispatch
└── services/
└── trackSyncService.js # EXISTS: Used by consolidated sync
```
### Pattern 1: Single Debounced Track Sync
**What:** Replace 3 sequential setTimeout calls with one debounced call
**When to use:** When multiple calls to the same function need consolidation
**Example:**
```javascript
// Source: Existing codebase pattern + lodash docs
// Before: JKSessionScreen.js lines 463-475 (3 separate timers)
// After: Single debounced call
import { useMemo, useRef, useEffect } from 'react';
import { debounce } from 'lodash';
// In JKSessionScreen
const syncTracksDebounced = useMemo(() =>
debounce((sessionId, clientId, dispatch) => {
dispatch(syncTracksToServer(sessionId, clientId));
}, 1000), // 1 second delay adequate for mixer initialization
[]
);
useEffect(() => {
if (!hasJoined || !sessionId || !server?.clientId || !mixersReady) {
return;
}
syncTracksDebounced(sessionId, server.clientId, dispatch);
return () => syncTracksDebounced.cancel();
}, [hasJoined, sessionId, mixersReady, dispatch, syncTracksDebounced]);
```
### Pattern 2: Ref-Based Debounce with Fresh Closure
**What:** Use useRef to always access latest state while keeping debounce timer stable
**When to use:** Debounced callback needs access to changing state/props
**Example:**
```javascript
// Source: https://www.developerway.com/posts/debouncing-in-react
// Before: useSessionModel.js lines 605-614
// const trackChanges = useCallback(debounce((header, payload) => {
// if (currentTrackChanges < payload.track_changes_counter) { ... }
// }, 500), [currentTrackChanges, refreshCurrentSession]);
// Problem: debounce recreated when currentTrackChanges changes
// After: Ref-based stable debounce
const trackChangesRef = useRef();
// Keep ref updated with latest values
useEffect(() => {
trackChangesRef.current = { currentTrackChanges, refreshCurrentSession };
}, [currentTrackChanges, refreshCurrentSession]);
// Debounced function created ONCE, always reads from ref
const trackChanges = useMemo(() =>
debounce((header, payload) => {
const { currentTrackChanges, refreshCurrentSession } = trackChangesRef.current;
if (currentTrackChanges < payload.track_changes_counter) {
refreshCurrentSession();
}
}, 500),
[] // Empty deps: created once, never recreated
);
```
### Pattern 3: State Colocation for Button Loading States
**What:** Move loading state from parent to child component that uses it
**When to use:** State only affects one child component's rendering
**Example:**
```javascript
// Source: https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster
// Before: JKSessionScreen.js
const [resyncLoading, setResyncLoading] = useState(false);
// ... 1800 lines later ...
<Button onClick={handleResync} disabled={resyncLoading}>
{resyncLoading ? <Spinner /> : 'Resync'}
</Button>
// Problem: JKSessionScreen re-renders when resyncLoading changes
// After: JKResyncButton.js (new file)
const JKResyncButton = ({ resyncAudio }) => {
const [loading, setLoading] = useState(false);
const handleClick = async (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
try {
await resyncAudio();
} catch (error) {
toast.error('Audio resync failed: ' + error.message);
} finally {
setLoading(false);
}
};
return (
<Button onClick={handleClick} disabled={loading}>
{loading ? <><Spinner size="sm" /> Resyncing...</> : 'Resync'}
</Button>
);
};
// JKSessionScreen.js uses:
<JKResyncButton resyncAudio={resyncAudio} />
// Now JKSessionScreen doesn't re-render for button loading changes
```
### Pattern 4: Content Comparison Before Redux Dispatch
**What:** Compare array contents before dispatching updates to prevent unnecessary renders
**When to use:** Dispatching arrays that may have same content but different references
**Example:**
```javascript
// Source: Redux best practices + lodash docs
// Before: useMixerHelper.js lines 291-295
// dispatch(setMetronomeTrackMixers(metronomeTrackMixers));
// dispatch(setBackingTrackMixers(backingTrackMixers));
// Problem: Always dispatches even if content identical
// After: Compare before dispatch
const arraysEqual = (a, b) => {
if (a.length !== b.length) return false;
// For mixer arrays, compare by mixer ID
const aIds = a.map(m => m.master?.id || m.personal?.id).sort();
const bIds = b.map(m => m.master?.id || m.personal?.id).sort();
return aIds.every((id, i) => id === bIds[i]);
};
// In organizeMixers callback
const prevMetronome = useSelector(selectMetronomeTrackMixers);
const prevBacking = useSelector(selectBackingTrackMixers);
// etc.
if (!arraysEqual(metronomeTrackMixers, prevMetronome)) {
dispatch(setMetronomeTrackMixers(metronomeTrackMixers));
}
if (!arraysEqual(backingTrackMixers, prevBacking)) {
dispatch(setBackingTrackMixers(backingTrackMixers));
}
```
### Anti-Patterns to Avoid
- **Multiple timers for same operation:** Use debounce instead of setTimeout chains
- **Debounce in useCallback with dependencies:** Recreates debounce timer on every dependency change
- **Loading state in parent for child-only UI:** Causes parent re-renders; colocate to child
- **Always dispatching arrays:** Compare content first; new array reference triggers re-renders even with same content
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Debounce with stable closure | Manual setTimeout with cleanup | useMemo + useRef pattern | Handles cleanup, cancellation, and closure correctly |
| Array content comparison | Deep equality with JSON.stringify | Custom ID-based comparison | JSON.stringify is slow and fragile for complex objects |
| Timer consolidation | Multiple setTimeout calls | Single debounce call | Debounce handles edge cases (rapid calls, cleanup) |
| Button loading state isolation | Prop drilling from parent | useState in button component | State colocation principle; better performance |
**Key insight:** React's functional component model requires explicit management of closures and references. The patterns here use React's own primitives (useRef, useMemo) rather than external solutions, keeping the implementation aligned with React's design.
## Common Pitfalls
### Pitfall 1: Debounce Recreated on Dependency Change
**What goes wrong:** Each state change recreates the debounce function, resetting its internal timer
**Why it happens:** Dependencies in useCallback trigger function recreation
**How to avoid:** Use useRef to store callback with current values; create debounce function once with useMemo
**Warning signs:** Debounced function fires more often than expected; console logs show rapid fire despite debounce
### Pitfall 2: Multiple setTimeout for Same Operation
**What goes wrong:** Each timer fires independently; can't cancel properly on unmount
**Why it happens:** Copied from legacy code or misunderstanding of debounce
**How to avoid:** Replace with single debounce call; ensure proper cleanup in useEffect return
**Warning signs:** API called multiple times on single user action; cleanup warnings in React DevTools
### Pitfall 3: State in Parent Affecting Only Child
**What goes wrong:** Parent component re-renders for state changes only affecting child UI
**Why it happens:** State lifted too high; not following colocation principle
**How to avoid:** Move state to the lowest component that needs it
**Warning signs:** React DevTools Profiler shows parent re-rendering for button click; memo doesn't help
### Pitfall 4: Always Dispatching to Redux
**What goes wrong:** Component re-renders because Redux state reference changed (even if content same)
**Why it happens:** New array created and dispatched without checking if content changed
**How to avoid:** Compare meaningful content (IDs, lengths) before dispatch; skip if unchanged
**Warning signs:** Selector recomputes on every render; React DevTools shows component updating with same data
### Pitfall 5: Stale Closure in Debounced Callback
**What goes wrong:** Debounced function uses outdated state values
**Why it happens:** Closure captured old values at creation time
**How to avoid:** Use ref pattern - update ref.current with latest values, read from ref in debounce
**Warning signs:** Wrong values logged/used in debounced function; behavior differs from expectation
## Code Examples
Verified patterns from official sources and codebase:
### Custom useDebounceCallback Hook
```javascript
// Source: https://www.developerway.com/posts/debouncing-in-react
// Encapsulates ref-based debounce pattern for reuse
import { useRef, useEffect, useMemo } from 'react';
import { debounce } from 'lodash';
export const useDebounceCallback = (callback, delay = 500) => {
const callbackRef = useRef(callback);
// Always keep ref current
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Create debounced function once
const debouncedFn = useMemo(() =>
debounce((...args) => {
callbackRef.current?.(...args);
}, delay),
[delay]
);
// Cleanup on unmount
useEffect(() => {
return () => debouncedFn.cancel();
}, [debouncedFn]);
return debouncedFn;
};
// Usage in useSessionModel:
const trackChanges = useDebounceCallback((header, payload) => {
if (currentTrackChanges < payload.track_changes_counter) {
refreshCurrentSession();
}
}, 500);
```
### Self-Contained Loading Button Component
```javascript
// Source: Kent C. Dodds state colocation blog
// Pattern for button with colocated loading state
import React, { useState, useCallback } from 'react';
import { Button, Spinner } from 'reactstrap';
import { toast } from 'react-toastify';
export const JKResyncButton = ({ resyncAudio, className }) => {
const [loading, setLoading] = useState(false);
const handleClick = useCallback(async (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
try {
await resyncAudio();
} catch (error) {
if (error.message === 'timeout') {
toast.error('Audio resync timed out. Please try again.');
} else {
toast.error('Audio resync failed: ' + (error.message || 'Unknown error'));
}
} finally {
setLoading(false);
}
}, [resyncAudio, loading]);
return (
<Button
className={className || 'btn-custom-outline'}
outline
size="md"
onClick={handleClick}
disabled={loading}
>
{loading ? <><Spinner size="sm" /> Resyncing...</> : 'Resync'}
</Button>
);
};
```
### Conditional Redux Dispatch Pattern
```javascript
// Source: Redux best practices
// Only dispatch when content meaningfully changes
// Helper function for mixer array comparison
const mixerArraysEqual = (prev, next) => {
if (prev.length !== next.length) return false;
if (prev === next) return true; // Same reference
// Compare by mixer pair IDs (stable identifiers)
const prevIds = prev.map(pair =>
(pair.master?.id || '') + '-' + (pair.personal?.id || '')
).sort();
const nextIds = next.map(pair =>
(pair.master?.id || '') + '-' + (pair.personal?.id || '')
).sort();
return prevIds.every((id, i) => id === nextIds[i]);
};
// In useMixerHelper organizeMixers
const categorizeMixers = useCallback(() => {
// ... categorization logic ...
// Conditional dispatch - only if content changed
if (!mixerArraysEqual(prevMetronome, metronomeTrackMixers)) {
dispatch(setMetronomeTrackMixers(metronomeTrackMixers));
}
// ... etc for other categories
}, [/* deps */]);
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Multiple setTimeout chains | Single debounce call | Common practice | Cleaner code, proper cleanup, edge case handling |
| useCallback with debounce | useMemo + useRef pattern | React 16.8+ era | Stable timer, fresh closure access |
| All state in parent | State colocation | Kent C. Dodds blog (2019) | Fewer parent re-renders, better isolation |
| Always dispatch arrays | Content comparison first | Redux performance docs | Prevents unnecessary selector recomputation |
**Deprecated/outdated:**
- **useCallback for debounced functions**: Creates stale closures or recreates timer; use useMemo + useRef
- **componentShouldUpdate for loading buttons**: Functional components with colocated state are simpler
- **React 18 useEvent hook**: Would solve closure problem but not available in React 16.13.1
## Open Questions
Things that couldn't be fully resolved:
1. **Optimal Track Sync Delay**
- What we know: Legacy used 1s, 1.4s, 6s delays for 3 calls; 1s worked for initial sync
- What's unclear: Why 6s delay was added (possibly for slow network conditions)
- Recommendation: Start with single 1.5s debounced call; monitor for sync issues; can increase if needed
2. **JKSessionScreen Re-render Measurement**
- What we know: Success criteria requires 50%+ reduction in re-renders
- What's unclear: Exact current count; varies by user actions
- Recommendation: Use React DevTools Profiler before/after changes; focus on session join flow
3. **Other State Colocation Candidates**
- What we know: videoLoading, resyncLoading are clear candidates
- What's unclear: Which other useState in JKSessionScreen (16 total) could be colocated
- Recommendation: Audit during implementation; COLOC-03 requirement covers this
## Sources
### Primary (HIGH confidence)
- [developerway.com: Debouncing in React](https://www.developerway.com/posts/debouncing-in-react) - Ref-based debounce pattern, stale closure solutions
- [Kent C. Dodds: State Colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster) - State colocation principles and performance benefits
- [React Official: useCallback](https://react.dev/reference/react/useCallback) - Hook API and dependency management
- [Redux: Performance Normalization](https://redux.js.org/tutorials/essentials/part-6-performance-normalization) - Preventing unnecessary dispatches
### Secondary (MEDIUM confidence)
- [Dmitri Pavlutin: Debounce/Throttle in React](https://dmitripavlutin.com/react-throttle-debounce/) - Alternative debounce patterns
- [Kyle Shevlin: Debounce with Hooks](https://kyleshevlin.com/debounce-and-throttle-callbacks-with-react-hooks/) - Custom hook patterns
- [TKDodo: Hooks and Stale Closures](https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures) - Understanding closure issues
### Tertiary (LOW confidence)
- [thewriting.dev: Fixing React Debounce](https://thewriting.dev/fixing-react-debounce/) - Community patterns, confirmed by primary sources
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - React built-ins and lodash already in codebase
- Architecture patterns: HIGH - Official docs and established blogs provide clear guidance
- Pitfalls: HIGH - Well-documented common issues with verified solutions
- Track sync consolidation: MEDIUM - Optimal delay requires testing; pattern is solid
**Research date:** 2026-03-05
**Valid until:** 60 days (React 16.13.1 stable; patterns well-established)