jam-cloud/.planning/phases/30-component-memoization/30-RESEARCH.md

27 KiB

Phase 30: Component Memoization - Research

Researched: 2026-03-05 Domain: React component memoization with React.memo Confidence: HIGH

Summary

Phase 30 focuses on wrapping parent and container components (JKSessionAudioInputs and JKSessionRemoteTracks) with React.memo to prevent unnecessary re-renders when parent components re-render with unchanged props. Phase 29 already completed the foundational work: context providers are memoized, function references are stable, and child components (SessionTrackVU, SessionTrackGain, JKSessionMyTrack) are already wrapped with React.memo.

The remaining work is straightforward: apply React.memo to the two container components that orchestrate track rendering. However, success depends critically on prop stability - memoization is completely useless if props passed are always different (inline objects, new function references, or unstable context values). Since Phase 29 stabilized the context and helper functions, the conditions for effective memoization are already in place.

React.memo performs shallow comparison of props by default using Object.is. Custom comparison functions are rarely needed and can hurt performance if they're more expensive than re-rendering. The key insight is that React.memo alone is not enough - it must be combined with useMemo and useCallback at the call site to ensure stable prop references.

Primary recommendation: Wrap JKSessionAudioInputs and JKSessionRemoteTracks with React.memo using default shallow comparison. Add displayName for debugging. Verify with React DevTools Profiler that render counts remain stable when parent re-renders without prop changes.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
React.memo built-in (16.13.1) Prevent component re-renders Official React API for memoizing functional components
React DevTools latest Verify memoization effectiveness Official debugging tool with Profiler for tracking renders

Supporting

Library Version Purpose When to Use
useMemo built-in (16.13.1) Stabilize object/array props Parent components passing complex props to memo-wrapped children
useCallback built-in (16.13.1) Stabilize function props Parent components passing callbacks to memo-wrapped children
displayName built-in property Component naming in DevTools All memo-wrapped components for debugging

Alternatives Considered

Instead of Could Use Tradeoff
React.memo (default) React.memo with custom comparison Custom comparison can be slower than re-rendering; use only when proven necessary
React.memo PureComponent PureComponent is for class components; functional components with memo are codebase standard
React.memo React Compiler (React 19+) React Compiler not available in React 16.13.1; future upgrade path

Installation:

# No new dependencies required
# React.memo is built into React 16.13.1+

Architecture Patterns

src/
├── components/
│   └── client/
│       ├── JKSessionAudioInputs.js       # WRAP: React.memo + displayName
│       ├── JKSessionRemoteTracks.js      # WRAP: React.memo + displayName
│       ├── JKSessionMyTrack.js           # ALREADY MEMOIZED (Phase 29)
│       ├── SessionTrackVU.js             # ALREADY MEMOIZED (Phase 29)
│       ├── SessionTrackGain.js           # ALREADY MEMOIZED (Phase 29)
│       └── JKSessionScreen.js            # PARENT: Verify stable props passed

Pattern 1: Basic React.memo Wrapper

What: Wrap a functional component with React.memo to skip re-renders when props are shallowly equal. When to use: For components that receive props from parent and re-render frequently despite unchanged props. Example:

// Source: React official docs https://react.dev/reference/react/memo
import React, { memo } from 'react';

// Named function expression for better DevTools display
const JKSessionAudioInputs = memo(function JKSessionAudioInputs({
  myTracks,
  chat,
  mixerHelper,
  isRemote = false,
  mixType = 'default'
}) {
  // Component implementation
  return (
    // JSX
  );
});

// Add displayName for debugging (DevTools will show this name)
JKSessionAudioInputs.displayName = 'JKSessionAudioInputs';

export default JKSessionAudioInputs;

Pattern 2: Memo with useMemo for Computed Props

What: Combine React.memo on child with useMemo on parent to stabilize computed prop values. When to use: When parent component derives props through computation before passing to memoized child. Example:

// Source: React memo docs + codebase pattern
// Already implemented in JKSessionRemoteTracks.js

const JKSessionRemoteTracks = memo(function JKSessionRemoteTracks({
  mixerHelper,
  sessionModel
}) {
  // Memoize expensive computation - stable result prevents child re-renders
  const remoteParticipantsData = useMemo(() => {
    // Complex computation deriving participant data
    return participants.map(participant => ({
      // ... computed data
    }));
  }, [currentSession, currentUser, sessionModel, mixerHelper]);

  return (
    <div>
      {remoteParticipantsData.map(({ participant, tracks }) => (
        <JKSessionAudioInputs
          key={participant.client_id}
          myTracks={tracks}
          mixerHelper={mixerHelper}
          isRemote={true}
        />
      ))}
    </div>
  );
});

JKSessionRemoteTracks.displayName = 'JKSessionRemoteTracks';

Pattern 3: Verify Prop Stability at Call Site

What: Ensure parent component passes stable references (not inline objects/functions) to memoized children. When to use: Always, when wrapping components with memo - verify at call sites. Example:

// Source: Codebase pattern from JKSessionScreen.js
// BEFORE memo (if props are unstable):
<JKSessionAudioInputs
  myTracks={mixerHelper.myTracks}  // ✅ Stable - from memoized context
  chat={chat}                       // ✅ Stable - state variable
  mixerHelper={mixerHelper}         // ✅ Stable - memoized context value
  isRemote={false}                  // ✅ Stable - primitive literal
/>

// ANTI-PATTERN (breaks memoization):
<JKSessionAudioInputs
  myTracks={mixerHelper.myTracks}
  chat={chat}
  mixerHelper={mixerHelper}
  style={{ gap: '0.5rem' }}        // ❌ Inline object - new reference every render
  onTrackClick={() => {}}          // ❌ Inline function - new reference every render
/>

Pattern 4: Custom Comparison (Rare)

What: Provide custom comparison function as second argument to React.memo. When to use: Only when default shallow comparison is insufficient AND profiling proves custom comparison is faster than re-rendering. Example:

// Source: React memo docs https://react.dev/reference/react/memo
// ONLY use if profiling shows benefit

const JKSessionAudioInputs = memo(
  function JKSessionAudioInputs({ myTracks, chat, mixerHelper, isRemote }) {
    // Component implementation
  },
  (oldProps, newProps) => {
    // Return true if props are equal (skip render)
    // Return false if props differ (allow render)

    // Example: deep comparison of tracks array
    if (oldProps.myTracks.length !== newProps.myTracks.length) {
      return false; // Different length, must re-render
    }

    // MUST compare ALL props, including functions
    return oldProps.myTracks.every((oldTrack, i) =>
      oldTrack.track?.client_track_id === newProps.myTracks[i].track?.client_track_id
    ) && oldProps.mixerHelper === newProps.mixerHelper;
  }
);

// WARNING: Custom comparison can be slower than re-rendering
// Always benchmark with React DevTools Profiler in production mode

Anti-Patterns to Avoid

  • Inline objects/arrays as props: <Component style={{ width: 100 }} /> breaks memoization (new object every render)
  • Inline callbacks: <Component onClick={() => handler()} /> breaks memoization (new function every render)
  • Children prop without memoization: Nesting JSX children defeats memoization unless children are memoized
  • Memoizing everything: Don't use memo if component rarely re-renders or is cheap to render
  • Incomplete custom comparison: If custom comparison doesn't check all props (especially functions), stale closures cause bugs
  • Deep equality checks without profiling: Deep comparison can freeze app if data structure is large/nested

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Props equality comparison Custom shallow equality function React.memo default behavior React's Object.is is optimized, handles edge cases, tested at scale
Component display names Manual name tracking displayName property DevTools recognizes displayName; ESLint can enforce via eslint-plugin-react
Render counting Manual render tracking React DevTools Profiler Profiler shows render counts, durations, actualDuration vs baseDuration
Memoization verification Console.log render tracking React DevTools Profiler Flamegraph Profiler visualizes re-render cascades, highlights skipped renders

Key insight: React.memo's default shallow comparison covers 95% of use cases. Custom comparison functions introduce maintenance burden and performance risk. Always measure with DevTools before adding custom comparison.

Common Pitfalls

Pitfall 1: Inline Props Break Memoization

What goes wrong: Component wrapped with memo still re-renders on every parent render. Why it happens: Parent passes inline object (style={{ gap: '0.5rem' }}) or inline function (onClick={() => {}}) as prop, creating new reference each render. How to avoid:

  • Extract inline objects to useMemo: const style = useMemo(() => ({ gap: '0.5rem' }), [])
  • Extract inline functions to useCallback: const handleClick = useCallback(() => {}, [])
  • Pass primitive values instead of objects when possible Warning signs: React DevTools Profiler shows memoized component re-rendering despite "no prop changes"

Pitfall 2: Children Prop Defeats Memoization

What goes wrong: Memoized component re-renders because children prop always changes. Why it happens: JSX children are props too - parent re-creating child JSX creates new reference. How to avoid:

  • Composition pattern: Let parent pass stable children as prop
  • Memoize child components individually rather than wrapping parent with children
  • If children are static, extract to constant outside component Warning signs: Profiler shows re-render even when other props stable

Example:

// ANTI-PATTERN: Children defeat memo
const Parent = () => {
  return (
    <MemoizedContainer>
      <Child />  {/* New element every render, breaks memo */}
    </MemoizedContainer>
  );
};

// SOLUTION: Memoize children or extract
const Parent = () => {
  const children = useMemo(() => <Child />, []);
  return <MemoizedContainer>{children}</MemoizedContainer>;
};

Pitfall 3: Context Changes Bypass Memo

What goes wrong: Memoized component re-renders when context value changes, ignoring memo. Why it happens: Context subscriptions force re-renders regardless of memo. React reconciler marks context-dependent fibers for update, bypassing memo bailout. How to avoid:

  • Memoize context provider value (Phase 29 completed this)
  • Split contexts by update frequency (Phase 28/29 completed this)
  • If context changes frequently, move context consumption to child component Warning signs: Memoized component re-renders on every context update

Note: Phase 29 already addressed this by memoizing MixersContext and VuContext values.

Pitfall 4: Custom Comparison Slower Than Re-render

What goes wrong: Performance gets worse after adding React.memo with custom comparison. Why it happens: Deep equality checks or complex comparison logic takes longer than re-rendering component. How to avoid:

  • Default to shallow comparison (no second argument to memo)
  • Only add custom comparison if profiling proves benefit
  • Benchmark custom comparison vs re-render with DevTools Performance panel
  • Keep custom comparison simple - bail out early on obvious differences Warning signs: Profiler shows increased overall render time after adding memo

Pitfall 5: Missing displayName in DevTools

What goes wrong: Memoized components show as "Anonymous" or "Memo" in DevTools, making debugging difficult. Why it happens: React can't infer name from arrow functions wrapped in memo. How to avoid:

  • Use named function expressions: memo(function ComponentName() {})
  • Set displayName explicitly: Component.displayName = 'ComponentName'
  • Enable ESLint rule react/display-name to catch missing names Warning signs: DevTools component tree shows "Anonymous" or generic "Memo" labels

Pitfall 6: Memoizing Components That Change Frequently

What goes wrong: Overhead of comparison checking outweighs re-render cost. Why it happens: Component props change on most parent renders, so memo comparison always returns false. How to avoid:

  • Profile first with DevTools - only memoize if many renders with same props
  • Don't memoize if component is cheap to render (simple JSX, no hooks)
  • Don't memoize if component always gets different props Warning signs: Profiler shows memo component renders just as often as non-memo version

Code Examples

Verified patterns from official sources:

Complete React.memo Implementation for JKSessionAudioInputs

// Source: React memo docs + Phase 29 patterns
// File: jam-ui/src/components/client/JKSessionAudioInputs.js

import React, { memo } from 'react';
import JKSessionMyTrack from './JKSessionMyTrack.js';
import { getInstrumentIcon45, convertClientInstrumentToServer } from '../../helpers/utils';

// Named function expression wrapped in memo
const JKSessionAudioInputs = memo(function JKSessionAudioInputs({
  myTracks,
  chat,
  mixerHelper,
  isRemote = false,
  mixType = 'default'
}) {
  return (
    <div className='d-flex' style={{ gap: '0.5rem' }}>
      <div>
        {myTracks.length === 0 && !chat ? (
          <div>No tracks available</div>
        ) : (
          <>
            {myTracks.map((track, index) => {
              const instrumentId = track.track?.instrument_id;
              const serverInstrument = typeof instrumentId === 'number'
                ? convertClientInstrumentToServer(instrumentId)
                : (instrumentId || track.track?.instrument);

              let selectedMixers = track.mixers;
              if (mixType === 'personal' && track.personalMixers) {
                selectedMixers = track.personalMixers;
              } else if (mixType === 'master' && track.masterMixers) {
                selectedMixers = track.masterMixers;
              }

              return (
                <div key={track.track.client_track_id || index}>
                  <JKSessionMyTrack
                    {...track}
                    mixers={selectedMixers}
                    instrument={serverInstrument}
                    mode={mixerHelper.mixMode}
                    isRemote={isRemote}
                  />
                </div>
              );
            })}
            {chat && (
              <JKSessionMyTrack
                key="chat"
                {...chat}
                trackName="Chat"
                instrument="headphones"
                hasMixer={true}
                isChat={true}
                isRemote={isRemote}
              />
            )}
          </>
        )}
      </div>
    </div>
  );
});

// Critical for debugging in React DevTools
JKSessionAudioInputs.displayName = 'JKSessionAudioInputs';

export default JKSessionAudioInputs;

Complete React.memo Implementation for JKSessionRemoteTracks

// Source: React memo docs + codebase pattern
// File: jam-ui/src/components/client/JKSessionRemoteTracks.js

import React, { useMemo, memo } from 'react';
import { useSelector } from 'react-redux';
import { selectActiveSession } from '../../store/features/activeSessionSlice';
import { useAuth } from '../../context/UserAuth';
import JKSessionAudioInputs from './JKSessionAudioInputs';
import { getAvatarUrl, getInstrumentIcon45_inverted } from '../../helpers/utils';

const JKSessionRemoteTracks = memo(function JKSessionRemoteTracks({
  mixerHelper,
  sessionModel
}) {
  const currentSession = useSelector(selectActiveSession);
  const { currentUser } = useAuth();

  // Memoize expensive computation - already present in current code
  const remoteParticipantsData = useMemo(() => {
    if (!currentSession || !currentUser) return [];

    const allParticipants = sessionModel.participants();
    const remoteParticipants = allParticipants.filter(participant =>
      participant.user.id !== currentUser.id
    );

    return remoteParticipants.map(participant => {
      const tracks = [];
      const connStatsClientId = participant.client_role === 'child' && participant.parent_client_id
        ? participant.parent_client_id
        : participant.client_id;

      const photoUrl = getAvatarUrl(participant.user.photo_url);
      const name = participant.user.name;

      for (const track of participant.tracks || []) {
        const mixerData = mixerHelper.findMixerForTrack(
          participant.client_id,
          track,
          false,
          mixerHelper.mixMode
        );
        const hasMixer = !!mixerData.mixer;
        const instrumentIcon = getInstrumentIcon45_inverted(
          track.instrument_id || track.instrument
        );
        const trackName = name;

        tracks.push({
          track,
          mixerFinder: [participant.client_id, track, false],
          mixers: mixerData,
          hasMixer,
          name,
          trackName,
          instrumentIcon,
          photoUrl,
          clientId: participant.client_id,
          userId: participant.user.id,
          connStatsClientId
        });
      }

      return {
        participant,
        tracks,
        hasChat: false
      };
    });
  }, [currentSession, currentUser, sessionModel, mixerHelper]);

  return (
    <div className='d-flex' style={{ gap: '1rem' }}>
      {remoteParticipantsData.map(({ participant, tracks, hasChat }) => (
        <JKSessionAudioInputs
          key={participant.client_id}
          myTracks={tracks}
          chat={hasChat ? {} : null}
          mixerHelper={mixerHelper}
          isRemote={true}
        />
      ))}
    </div>
  );
});

// Critical for debugging in React DevTools
JKSessionRemoteTracks.displayName = 'JKSessionRemoteTracks';

export default JKSessionRemoteTracks;

Verifying Memoization with React DevTools Profiler

// Source: React DevTools Profiler best practices
// Manual testing procedure after implementing React.memo

/**
 * VERIFICATION STEPS:
 *
 * 1. Open React DevTools (browser extension)
 * 2. Go to Profiler tab
 * 3. Click Record button (red circle)
 * 4. Trigger parent re-render (e.g., adjust a session setting)
 * 5. Stop recording
 * 6. Examine Flamegraph and Ranked views
 *
 * SUCCESS CRITERIA:
 *
 * - JKSessionAudioInputs: Should show "Did not render" when myTracks unchanged
 * - JKSessionRemoteTracks: Should show "Did not render" when mixerHelper/sessionModel unchanged
 * - Render count: Should remain stable (not increment) when props don't change
 * - actualDuration: Should be 0ms or very low for skipped renders
 *
 * COMPARISON METRICS:
 *
 * - actualDuration: Time spent rendering with memoization optimizations
 * - baseDuration: Time without any optimizations (baseline)
 * - If actualDuration << baseDuration, memoization is working
 *
 * COMMON ISSUES:
 *
 * - Component still renders: Check for inline props at call site
 * - "Did not skip render": Verify parent passes stable references
 * - Render count increments: Props are changing on every parent render
 */

// Example DevTools inspection code (for debugging):
// In component, add temporary effect to log renders
useEffect(() => {
  console.log('JKSessionAudioInputs rendered with props:', {
    myTracks,
    chat,
    mixerHelper,
    isRemote
  });
});

Call Site Verification Pattern

// Source: Codebase JKSessionScreen.js
// Verify props passed to memoized components are stable

// CORRECT (stable props):
const JKSessionScreen = () => {
  const mixerHelper = useMixersContext(); // ✅ Memoized context
  const chat = useChatState();            // ✅ State variable
  const sessionModel = useSessionModel(); // ✅ Hook return value

  return (
    <>
      <JKSessionAudioInputs
        myTracks={mixerHelper.myTracks}  // ✅ Property of memoized context
        chat={chat}                       // ✅ Direct state reference
        mixerHelper={mixerHelper}         // ✅ Memoized context value
        isRemote={false}                  // ✅ Primitive literal
      />

      <JKSessionRemoteTracks
        mixerHelper={mixerHelper}         // ✅ Memoized context value
        sessionModel={sessionModel}       // ✅ Hook return (stable if hook memoizes)
      />
    </>
  );
};

// INCORRECT (breaks memoization):
const JKSessionScreen = () => {
  const mixerHelper = useMixersContext();

  return (
    <>
      <JKSessionAudioInputs
        myTracks={mixerHelper.myTracks}
        chat={null}
        mixerHelper={mixerHelper}
        isRemote={false}
        config={{ showAvatar: true }}     // ❌ Inline object - new reference
        onTrackClick={() => {}}           // ❌ Inline function - new reference
      />
    </>
  );
};

State of the Art

Old Approach Current Approach When Changed Impact
PureComponent (class) React.memo (functional) React 16.6 (2018) Simpler API, works with hooks
Manual shouldComponentUpdate React.memo default comparison React 16.6 (2018) Less boilerplate, handles most cases
Deep comparison libraries Shallow comparison + useMemo React 16.8+ (2019) Cheaper, more predictable
Always memoize everything Profile first, memoize strategically Best practice (2020+) Avoid premature optimization
Context + memo issues Memoized context values Best practice (2021+) Memo works correctly with stable context
React.memo everywhere React Compiler (auto-memoization) React 19 (2024) Compiler handles memoization automatically

Deprecated/outdated:

  • PureComponent for functional components: Use React.memo instead
  • Deep equality comparison by default: Too expensive; use shallow comparison + useMemo for objects
  • Memoizing without measuring: Always profile with DevTools first
  • Context without memoization: Context providers must memoize value to make consumer memo effective

Future direction:

  • React Compiler (React 19+): Automatically inserts memoization where needed, reducing manual React.memo usage
  • Selective Hydration (React 18+): Works with memo to prioritize which components hydrate first
  • useContextSelector (React 19+): Built-in context selectors make memo more effective with context

Open Questions

Things that couldn't be fully resolved:

  1. Exact re-render frequency of JKSessionScreen parent

    • What we know: JKSessionScreen is the parent rendering JKSessionAudioInputs and JKSessionRemoteTracks
    • What's unclear: How frequently does JKSessionScreen re-render, and what triggers those re-renders?
    • Recommendation: Profile with DevTools before and after to measure actual impact of memoization
  2. sessionModel stability

    • What we know: sessionModel passed as prop to JKSessionRemoteTracks from useSessionModel hook
    • What's unclear: Does useSessionModel return stable reference, or does it recreate object on each call?
    • Recommendation: Audit useSessionModel.js to verify it uses useMemo or returns stable reference
  3. myTracks array reference stability

    • What we know: myTracks comes from mixerHelper.myTracks (Phase 29 memoized mixerHelper)
    • What's unclear: Is myTracks array itself stable, or is it recreated (with same content) on updates?
    • Recommendation: If myTracks array recreates with same tracks, might need custom comparison function (profile first)
  4. Testing strategy without automated tests

    • What we know: No existing tests for JKSessionAudioInputs or JKSessionRemoteTracks
    • What's unclear: Best approach to verify memoization works without regression suite
    • Recommendation: Manual DevTools Profiler testing for this phase; consider adding render count tests in future

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - React.memo is built into React 16.6+, well-documented
  • Architecture: HIGH - Patterns verified against official React docs and established best practices
  • Pitfalls: HIGH - Common issues documented extensively in React docs and community resources

Research date: 2026-03-05 Valid until: 2026-04-05 (30 days - stable React patterns, React 16.13.1 environment)

Key constraints:

  • React 16.13.1 (no React Compiler auto-memoization available)
  • Phase 29 completed context value memoization (MixersContext, VuContext stable)
  • SessionTrackVU and SessionTrackGain already wrapped with memo (Phase 29)
  • JKSessionMyTrack already wrapped with memo (Phase 29)
  • Must verify prop stability at call sites (JKSessionScreen.js)

Dependencies on Phase 29:

  • MixersContext.Provider value memoized - ensures mixerHelper reference stable
  • useMixerHelper functions use useCallback - ensures function props stable
  • Context consumers already memoized - prevents re-render cascades
  • React.memo pattern established - this phase extends same pattern to parent containers