From bb0019c9418e2a1da7b7366a38b4470f85848e5b Mon Sep 17 00:00:00 2001 From: Nuwan Date: Thu, 5 Mar 2026 18:53:42 +0530 Subject: [PATCH] docs(31): research selector optimization phase domain Phase 31: Selector Optimization - Standard stack identified (createSelector, shallowEqual in Redux Toolkit) - Architecture patterns documented (composed selectors, memoization) - Pitfalls catalogued (reference equality, over-subscription, cache thrashing) Co-Authored-By: Claude Opus 4.5 --- .../31-selector-optimization/31-RESEARCH.md | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 .planning/phases/31-selector-optimization/31-RESEARCH.md diff --git a/.planning/phases/31-selector-optimization/31-RESEARCH.md b/.planning/phases/31-selector-optimization/31-RESEARCH.md new file mode 100644 index 000000000..6020bb579 --- /dev/null +++ b/.planning/phases/31-selector-optimization/31-RESEARCH.md @@ -0,0 +1,501 @@ +# Phase 31: Selector Optimization - Research + +**Researched:** 2026-03-05 +**Domain:** Redux Toolkit selector performance optimization (React 16.13.1) +**Confidence:** HIGH + +## Summary + +Redux selector optimization addresses the performance bottleneck caused by excessive individual `useSelector` calls. The codebase's `useMixerHelper` hook currently uses 18+ individual selectors (lines 72-92), causing unnecessary re-renders when any single mixer field changes. Each selector runs independently, and React 16's default reference equality check (`===`) triggers re-renders even when derived data hasn't meaningfully changed. + +The solution involves three complementary techniques: (1) composed selectors using `createSelector` for memoized derived data, (2) `shallowEqual` comparison for object selectors to prevent reference inequality issues, and (3) consolidating related selectors to reduce subscription overhead. Redux Toolkit includes Reselect's `createSelector` by default, making this a zero-dependency optimization. + +**Primary recommendation:** Use `createSelector` to compose mixer state into 3-5 memoized selectors instead of 18+ individual ones, apply `shallowEqual` for multi-property object returns, and restructure selectors to group related data that changes together. + +## Standard Stack + +The established libraries/tools for Redux selector optimization in React 16: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| @reduxjs/toolkit | 1.6.1 (current) | Includes createSelector | Official Redux toolkit, bundles Reselect | +| react-redux | 7.x-8.x | useSelector hook + shallowEqual | React 16 requires v7/v8 (v9 is React 18 only) | +| reselect | 4.x (bundled) | Memoized selector library | Industry standard, included in RTK | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| use-sync-external-store | 1.6.0 (current) | React 16 shim for external stores | Already in package.json for VU store | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| createSelector | Manual useMemo in hooks | Loses composition, harder to test, couples logic to components | +| shallowEqual | Deep equality library | Much slower, unnecessary for flat objects | +| Redux Toolkit | Standalone Reselect | Requires separate dependency, loses RTK integration | + +**Installation:** +```bash +# No installation needed - createSelector is already available via @reduxjs/toolkit +# shallowEqual is already available via react-redux +``` + +## Architecture Patterns + +### Recommended Selector Structure +``` +src/store/features/ +├── mixersSlice.js +│ ├── Basic selectors (selectMasterMixers, selectPersonalMixers) +│ └── Memoized selectors (selectMixerState, selectDerivedMixerData) +└── selectors/ + └── mixerSelectors.js # Composed cross-slice selectors (if needed) + +src/hooks/ +└── useMixerHelper.js # Uses 3-5 composed selectors, not 18+ +``` + +### Pattern 1: Composed Selector with createSelector +**What:** Combine multiple state slices into a single memoized selector +**When to use:** When multiple pieces of state are needed together and derive new data +**Example:** +```javascript +// Source: https://redux-toolkit.js.org/api/createSelector +import { createSelector } from '@reduxjs/toolkit'; + +// Input selectors (simple state extraction) +const selectMasterMixers = (state) => state.mixers.masterMixers; +const selectPersonalMixers = (state) => state.mixers.personalMixers; +const selectMixMode = (state) => state.sessionUI.mixMode; + +// Composed memoized selector +const selectActiveMixers = createSelector( + [selectMasterMixers, selectPersonalMixers, selectMixMode], + (masterMixers, personalMixers, mixMode) => { + // Only recalculates when inputs change + return mixMode === MIX_MODES.MASTER ? masterMixers : personalMixers; + } +); + +// Usage in component +function useMixerHelper() { + const activeMixers = useSelector(selectActiveMixers); + // Single selector subscription instead of 3 separate ones +} +``` + +### Pattern 2: Object Selector with shallowEqual +**What:** Return multiple fields in an object with shallow equality comparison +**When to use:** When component needs several related fields but derived computation is minimal +**Example:** +```javascript +// Source: https://react-redux.js.org/api/hooks +import { useSelector, shallowEqual } from 'react-redux'; + +// Without shallowEqual: Creates new object reference every time, always re-renders +const mixerConfig = useSelector(state => ({ + chatMixer: state.mixers.chatMixer, + broadcastMixer: state.mixers.broadcastMixer, + recordingMixer: state.mixers.recordingMixer +})); // BAD: Always re-renders + +// With shallowEqual: Only re-renders if field values change +const mixerConfig = useSelector(state => ({ + chatMixer: state.mixers.chatMixer, + broadcastMixer: state.mixers.broadcastMixer, + recordingMixer: state.mixers.recordingMixer +}), shallowEqual); // GOOD: Re-renders only when fields change +``` + +### Pattern 3: Grouped Related State +**What:** Consolidate selectors for data that changes together +**When to use:** Multiple fields from the same slice always used together +**Example:** +```javascript +// Source: https://redux.js.org/usage/deriving-data-selectors + +// BEFORE: 18+ individual selectors +const chatMixer = useSelector(selectChatMixer); +const broadcastMixer = useSelector(selectBroadcastMixer); +const recordingMixer = useSelector(selectRecordingMixer); +const recordingTrackMixers = useSelector(selectRecordingTrackMixers); +const backingTrackMixers = useSelector(selectBackingTrackMixers); +// ... 13 more individual selectors + +// AFTER: Composed selector groups related data +const selectCoreMixerState = createSelector( + [ + state => state.mixers.chatMixer, + state => state.mixers.broadcastMixer, + state => state.mixers.recordingMixer + ], + (chatMixer, broadcastMixer, recordingMixer) => ({ + chatMixer, + broadcastMixer, + recordingMixer + }) +); + +const coreMixers = useSelector(selectCoreMixerState, shallowEqual); +``` + +### Pattern 4: Parameterized Selectors with useMemo +**What:** Create unique selector instances per component for parameterized lookups +**When to use:** Selector needs arguments that vary per component instance +**Example:** +```javascript +// Source: https://redux.js.org/usage/deriving-data-selectors + +// Factory function creates new selector instance +const makeSelectMixerByResourceId = () => + createSelector( + [ + state => state.mixers.mixersByResourceId, + (state, resourceId) => resourceId + ], + (mixersByResourceId, resourceId) => mixersByResourceId[resourceId] + ); + +// Component creates unique instance with useMemo +function MixerControl({ resourceId }) { + const selectMixerByResourceId = useMemo(makeSelectMixerByResourceId, []); + const mixerPair = useSelector(state => + selectMixerByResourceId(state, resourceId) + ); +} +``` + +### Anti-Patterns to Avoid +- **Don't create new references in useSelector:** `useSelector(state => state.todos.filter(...))` creates new array every render. Use createSelector instead. +- **Don't over-memoize simple lookups:** `createSelector([state => state.value], value => value)` adds overhead for no benefit. Just use `useSelector(state => state.value)`. +- **Don't use createSelector in reducers:** Standard createSelector breaks with Immer drafts. Use `createDraftSafeSelector` if needed in reducers. +- **Don't ignore granularity:** Selecting entire large objects causes re-renders when unused fields change. Select only what's needed. +- **Don't chain useSelectors for derived data:** `const x = useSelector(...); const y = x.map(...)` re-derives on every render. Use createSelector to memoize derivation. + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Selector memoization | Manual useMemo/useCallback caching | createSelector | Handles input comparison, cache invalidation, composition, and testing automatically | +| Object equality checking | Custom shallow comparison function | shallowEqual from react-redux | Battle-tested, handles edge cases, optimized performance | +| Multi-component selector state | Singleton selector with global cache | Factory functions + useMemo | Prevents cache thrashing when multiple components use same selector with different params | +| Deep equality checking | JSON.stringify comparison | Don't - restructure selectors | Deep equality is slow; indicates selector returns too much data | + +**Key insight:** Redux selector optimization is a solved problem with mature tooling. The edge cases (Immer draft handling, cache invalidation, parameterized selectors, composition) are complex and already handled by Redux Toolkit's included Reselect library. + +## Common Pitfalls + +### Pitfall 1: Always Returning New References +**What goes wrong:** Selector creates new object/array every call, causing re-renders even when data hasn't changed +**Why it happens:** JavaScript's `filter()`, `map()`, object literals `{...}` always create new references +**How to avoid:** +```javascript +// BAD: New array reference every time +const completedTodos = useSelector(state => + state.todos.filter(todo => todo.completed) +); + +// GOOD: Memoized selector only creates new array when todos change +const selectCompletedTodos = createSelector( + [state => state.todos], + todos => todos.filter(todo => todo.completed) +); +const completedTodos = useSelector(selectCompletedTodos); +``` +**Warning signs:** Component re-renders after every Redux action, even unrelated ones + +### Pitfall 2: Over-Subscription with 18+ Individual Selectors +**What goes wrong:** Each `useSelector` subscribes to Redux store independently, causing 18+ equality checks per action +**Why it happens:** One selector per field feels natural but creates subscription overhead +**How to avoid:** Group related fields into composed selectors +```javascript +// BAD: 18+ subscriptions +const chatMixer = useSelector(selectChatMixer); +const broadcastMixer = useSelector(selectBroadcastMixer); +// ... 16 more + +// GOOD: 3-5 composed subscriptions +const coreMixers = useSelector(selectCoreMixerState, shallowEqual); +const trackMixers = useSelector(selectTrackMixerState, shallowEqual); +const derivedData = useSelector(selectDerivedMixerData); // memoized +``` +**Warning signs:** Hook runs 18+ times per mixer field change; profiler shows many shallow selector calls + +### Pitfall 3: Forgetting shallowEqual with Object Returns +**What goes wrong:** Selector returns object with same field values, but new reference triggers re-render +**Why it happens:** Default useSelector uses `===` reference equality +**How to avoid:** +```javascript +// BAD: Always re-renders (new object reference) +const state = useSelector(state => ({ + a: state.a, + b: state.b +})); + +// GOOD: Only re-renders when a or b changes +const state = useSelector(state => ({ + a: state.a, + b: state.b +}), shallowEqual); +``` +**Warning signs:** Component re-renders even though displayed data hasn't changed + +### Pitfall 4: Shared Parameterized Selector Cache Thrashing +**What goes wrong:** Multiple components use same selector with different params, invalidating cache constantly +**Why it happens:** createSelector has single-entry cache by default; different params = different inputs +**How to avoid:** Create unique selector instance per component +```javascript +// BAD: All MixerControl instances share one selector's cache +const selectMixerById = createSelector( + [state => state.mixers.allMixers, (state, id) => id], + (allMixers, id) => allMixers[id] +); + +function MixerControl({ id }) { + const mixer = useSelector(state => selectMixerById(state, id)); + // Cache invalidates when other MixerControl with different id renders +} + +// GOOD: Each instance has own selector with own cache +function MixerControl({ id }) { + const selectMixerById = useMemo(() => + createSelector( + [state => state.mixers.allMixers, (state, id) => id], + (allMixers, id) => allMixers[id] + ), + [] + ); + const mixer = useSelector(state => selectMixerById(state, id)); +} +``` +**Warning signs:** Selector memoization not working; profiler shows recalculations on every render + +### Pitfall 5: Using createSelector Inside Reducers +**What goes wrong:** Selector doesn't detect Immer draft changes, returns stale cached data +**Why it happens:** Immer drafts maintain same object reference even when mutated +**How to avoid:** Use `createDraftSafeSelector` in reducers (or don't use selectors in reducers) +**Warning signs:** Selector returns old data inside reducer; updates don't reflect + +## Code Examples + +Verified patterns from official sources: + +### Consolidating 18+ Selectors into Composed Groups +```javascript +// Source: https://redux.js.org/usage/deriving-data-selectors +// Before: useMixerHelper.js lines 72-92 (18+ individual selectors) + +import { useSelector, shallowEqual } from 'react-redux'; +import { createSelector } from '@reduxjs/toolkit'; + +// Define composed selectors in mixersSlice.js +export const selectCoreMixerState = createSelector( + [ + state => state.mixers.chatMixer, + state => state.mixers.broadcastMixer, + state => state.mixers.recordingMixer + ], + (chatMixer, broadcastMixer, recordingMixer) => ({ + chatMixer, + broadcastMixer, + recordingMixer + }) +); + +export const selectTrackMixerState = createSelector( + [ + state => state.mixers.recordingTrackMixers, + state => state.mixers.backingTrackMixers, + state => state.mixers.jamTrackMixers, + state => state.mixers.metronomeTrackMixers, + state => state.mixers.adhocTrackMixers + ], + (recordingTrackMixers, backingTrackMixers, jamTrackMixers, + metronomeTrackMixers, adhocTrackMixers) => ({ + recordingTrackMixers, + backingTrackMixers, + jamTrackMixers, + metronomeTrackMixers, + adhocTrackMixers + }) +); + +export const selectMixerLookupTables = createSelector( + [ + state => state.mixers.allMixers, + state => state.mixers.mixersByResourceId, + state => state.mixers.mixersByTrackId + ], + (allMixers, mixersByResourceId, mixersByTrackId) => ({ + allMixers, + mixersByResourceId, + mixersByTrackId + }) +); + +// Use in useMixerHelper hook +function useMixerHelper() { + // 3 selectors instead of 18+ + const coreMixers = useSelector(selectCoreMixerState, shallowEqual); + const trackMixers = useSelector(selectTrackMixerState, shallowEqual); + const lookupTables = useSelector(selectMixerLookupTables, shallowEqual); + + // Destructure for backward compatibility + const { chatMixer, broadcastMixer, recordingMixer } = coreMixers; + const { recordingTrackMixers, backingTrackMixers, jamTrackMixers, + metronomeTrackMixers, adhocTrackMixers } = trackMixers; + const { allMixers, mixersByResourceId, mixersByTrackId } = lookupTables; + + // Rest of hook logic unchanged +} +``` + +### Memoizing Derived Data Calculations +```javascript +// Source: https://redux-toolkit.js.org/api/createSelector +// For expensive transformations or filtering + +export const selectMasterMixersForMode = createSelector( + [ + state => state.mixers.masterMixers, + state => state.mixers.personalMixers, + state => state.sessionUI.mixMode + ], + (masterMixers, personalMixers, mixMode) => { + // This only recalculates when inputs change + return mixMode === MIX_MODES.MASTER ? masterMixers : personalMixers; + } +); + +// More complex derived data +export const selectSimulatedCategoryMixers = createSelector( + [ + selectMasterMixers, + selectPersonalMixers, + selectNoAudioUsers, + selectClientsWithAudioOverride + ], + (masterMixers, personalMixers, noAudioUsers, clientsWithAudioOverride) => { + // Complex calculation only runs when dependencies change + const musicMixers = masterMixers.filter(m => + m.group_id === ChannelGroupIds.AudioInputMusicGroup + ); + const chatMixers = masterMixers.filter(m => + m.group_id === ChannelGroupIds.ChatMicGroup + ); + + return { + music: { MASTER: musicMixers[0], PERSONAL: personalMixers[0] }, + chat: { MASTER: chatMixers[0], PERSONAL: personalMixers[0] } + }; + } +); +``` + +### Testing Composed Selectors +```javascript +// Source: https://redux.js.org/usage/deriving-data-selectors +// Selectors are pure functions - easy to unit test + +import { selectCoreMixerState } from './mixersSlice'; + +describe('selectCoreMixerState', () => { + test('returns core mixer state object', () => { + const state = { + mixers: { + chatMixer: { id: 1 }, + broadcastMixer: { id: 2 }, + recordingMixer: { id: 3 } + } + }; + + const result = selectCoreMixerState(state); + + expect(result).toEqual({ + chatMixer: { id: 1 }, + broadcastMixer: { id: 2 }, + recordingMixer: { id: 3 } + }); + }); + + test('memoizes result when inputs unchanged', () => { + const state = { + mixers: { + chatMixer: { id: 1 }, + broadcastMixer: { id: 2 }, + recordingMixer: { id: 3 } + } + }; + + const result1 = selectCoreMixerState(state); + const result2 = selectCoreMixerState(state); + + // Same reference = memoization working + expect(result1).toBe(result2); + }); +}); +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Multiple useSelector per field | Composed selectors with createSelector | Reselect 2.0+ (2016) | Reduced re-renders, better memoization | +| Manual memoization with useMemo | createSelector in slice files | Redux Toolkit 1.0 (2019) | Easier testing, composition, reusability | +| mapStateToProps (connect HOC) | useSelector hook | React-Redux 7.1 (2019) | Better performance with granular subscriptions | +| Custom equality functions | shallowEqual utility | React-Redux built-in | Standardized, optimized implementation | +| Separate reselect dependency | Bundled in Redux Toolkit | Redux Toolkit 1.0 (2019) | Zero additional dependencies | + +**Deprecated/outdated:** +- **mapStateToProps with connect()**: Still works but hooks (useSelector) are preferred for functional components +- **Redux Toolkit's configureStore includes reselect**: Don't install reselect separately unless you need advanced features not in RTK +- **String keypaths in createSelector**: Removed in Reselect 4.0 due to TypeScript typing issues + +## Open Questions + +Things that couldn't be fully resolved: + +1. **Cache Size Configuration** + - What we know: createSelector uses single-entry cache by default; Reselect 4.1+ supports custom cache sizes + - What's unclear: Whether Redux Toolkit 1.6.1 (project's version) includes newer Reselect with configurable cache + - Recommendation: Use factory pattern + useMemo for parameterized selectors (works with any Reselect version) + +2. **Optimal Granularity Balance** + - What we know: 18+ selectors is excessive; 1 giant selector is also suboptimal + - What's unclear: Exact threshold where consolidation stops being beneficial (depends on update patterns) + - Recommendation: Group selectors by logical domains (core mixers, track mixers, lookup tables) and measure with React DevTools Profiler + +3. **React 18 Migration Impact** + - What we know: React-Redux v9 requires React 18; current project uses React 16.13.1 with React-Redux 7.x/8.x + - What's unclear: Whether future React 18 upgrade changes selector optimization strategies + - Recommendation: Current patterns (createSelector, shallowEqual) remain valid in React 18; no strategy change needed + +## Sources + +### Primary (HIGH confidence) +- [Redux Toolkit createSelector API](https://redux-toolkit.js.org/api/createSelector) - Official documentation on memoized selectors +- [Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) - Official guide on selector patterns and best practices +- [React-Redux Hooks API](https://react-redux.js.org/api/hooks) - useSelector and shallowEqual documentation +- [Redux Performance Optimization](https://redux.js.org/tutorials/essentials/part-6-performance-normalization) - Official performance guidance + +### Secondary (MEDIUM confidence) +- [Redux Style Guide](https://redux.js.org/style-guide/) - Official recommendations for Redux patterns +- [GitHub: Reselect Library](https://github.com/reduxjs/reselect) - Source code and advanced patterns +- [Redux Toolkit Migration Guide](https://redux-toolkit.js.org/usage/migrating-rtk-2) - Compatibility information +- [Medium: Unleashing Redux Performance with useSelector](https://medium.com/@ProPhycientSawan/unleashing-redux-performance-with-useselector-e895c4d7b91b) - Performance analysis + +### Tertiary (LOW confidence) +- Various blog posts on selector optimization - General patterns confirmed by official docs + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - Redux Toolkit 1.6.1 includes everything needed; official docs confirm patterns +- Architecture: HIGH - Official Redux docs provide clear guidance on selector composition and structure +- Pitfalls: HIGH - Documented in official Redux tutorials with examples and explanations + +**Research date:** 2026-03-05 +**Valid until:** 60 days (stable ecosystem, Redux Toolkit 2.x released but project on 1.6.1)