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
Recommended Project Structure
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-nameto 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:
-
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
-
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
-
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)
-
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)
- React.memo official docs - Complete API, comparison behavior, context caveats
- React Profiler API docs - actualDuration vs baseDuration metrics
- React DevTools Profiler usage - Measuring render performance
Secondary (MEDIUM confidence)
- Use React.memo() wisely - Dmitri Pavlutin - When to use, when not to use
- React.memo Optimization Guide - Strapi - Practical patterns
- Fixing memoization-breaking re-renders - Sentry - Common pitfalls
- React Context Performance Trap - Azguards - Context + memo interaction
- How to Properly Memoize Context Values - Medium - Context memoization patterns
Tertiary (LOW confidence)
- Why children prop breaks React.memo - GitHub Gist - Children prop pitfall
- React.memo displayName issues - GitHub - DevTools display name handling
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