jam-cloud/.planning/phases/31-selector-optimization/31-RESEARCH.md

21 KiB

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:

# No installation needed - createSelector is already available via @reduxjs/toolkit
# shallowEqual is already available via react-redux

Architecture Patterns

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:

// 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:

// 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

What: Consolidate selectors for data that changes together When to use: Multiple fields from the same slice always used together Example:

// 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:

// 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:

// 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

// 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:

// 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

// 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

// 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

// 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

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

Secondary (MEDIUM confidence)

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)