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 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-03-05 18:53:42 +05:30
parent 773382d4f7
commit bb0019c941
1 changed files with 501 additions and 0 deletions

View File

@ -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)