This guide covers performance optimization patterns critical for maintaining optimal application responsiveness.
⚠️ CRITICAL: Before continuing, read the Zustand Subscription Patterns section in state-management.md. Violating these patterns causes severe performance degradation (15+ re-renders per keystroke). This is the #1 performance issue in the codebase.
- React Compiler (Automatic Memoization)
- Core Principles
- The
getState()Pattern - Store Subscription Optimization
- CSS Visibility vs Conditional Rendering
- Strategic React.memo Placement
- Memoization (Manual)
- Lazy Loading
- Debouncing
- React Compiler Patterns
- Anti-Patterns to Avoid
- Performance Testing
As of October 2025, we use React Compiler v1.0 which automatically optimizes React components and hooks through intelligent memoization. This eliminates the need for manual useMemo, useCallback, and React.memo in most cases.
The compiler automatically:
- Memoizes components and hooks based on their dependencies
- Optimizes granularly - can memoize values conditionally (better than manual memoization)
- Enforces Rules of React through stricter ESLint rules
- Prevents unnecessary re-renders without manual intervention
CRITICAL: React Compiler only memoizes React components and hooks. It does NOT optimize:
- Zustand store subscriptions - Selector syntax and
getState()patterns remain essential - Regular JavaScript functions - Only functions named like components/hooks
- External state management - Store patterns unchanged
- TanStack Query usage - Query optimization patterns still apply
Use manual useMemo/useCallback only when:
- Profiling shows a need - Measure first, optimize second
- Expensive computations the compiler can't detect (e.g., heavy calculations, large data transformations)
- Explicit control required - Third-party library integration or debugging
- Escape hatch - Temporary workaround for compiler issues (use
"use no memo"directive)
// ✅ Let compiler handle simple cases
function MyComponent({ data }) {
// Compiler automatically memoizes this
const filtered = data.filter(item => item.active)
return <List items={filtered} />
}
// ✅ Manual memoization for expensive operations (after profiling)
function ComplexComponent({ largeDataset }) {
// Expensive operation - manual memoization justified
const processed = useMemo(() => {
return largeDataset.map(item =>
expensiveTransformation(item) // Heavy calculation
).sort(complexSort)
}, [largeDataset])
return <DataGrid data={processed} />
}The compiler enforces React's Rules through new ESLint rules:
// ❌ set-state-in-effect - Warns about setState in effects
useEffect(() => {
// Avoid: Can cause cascading renders
setState(computeValue())
}, [dependency])
// ✅ Prefer derived state
const value = computeValue()
// ❌ preserve-manual-memoization - Requires complete dependencies
const value = useMemo(() => compute(a, b), [a]) // Missing 'b'
// ✅ Complete dependencies
const value = useMemo(() => compute(a, b), [a, b])
// ❌ exhaustive-deps - Stricter dependency checking
useEffect(() => {
doSomething(prop)
}, []) // Missing 'prop'
// ✅ Include all dependencies
useEffect(() => {
doSomething(prop)
}, [prop])Use the "use no memo" directive to prevent compiler optimization:
// Opt-out specific component
function ComponentWithSideEffects() {
"use no memo" // Directive must be first statement
// Component has side effects compiler might optimize incorrectly
logToAnalytics('component_rendered')
return <div>Content</div>
}
// ✅ Always document why
function ProblematicComponent() {
"use no memo" // TODO: Remove after fixing dynamic height issue (ISSUE-123)
// Implementation
}
// ❌ Don't use without explanation
function Mystery() {
"use no memo" // Why? No one knows!
// ...
}- Default to compiler optimization - Don't add manual memoization unless profiling shows a need
- Zustand patterns still critical - Compiler doesn't optimize store subscriptions
- Measure before optimizing - Use React DevTools Profiler
- Document opt-outs - Always explain
"use no memo"usage - Effect cleanup required - Use
cancelledflags (see React Compiler Patterns)
CRITICAL: Following these patterns is essential to prevent render cascades and maintain optimal performance.
- Minimize Re-renders: Components should only re-render when their displayed data changes
- Stable Dependencies: useCallback and useEffect dependencies should remain stable
- Prevent Cascades: One state change shouldn't trigger unnecessary re-renders in unrelated components
- Optimize Subscriptions: Subscribe only to data that should trigger re-renders
Core Principle: Subscribe only to data that should trigger component re-renders. For callbacks that need current state, use getState() to access values without subscribing.
When you destructure store values in a component, you create a subscription. Every time those values change, the component re-renders and all its callbacks are recreated, triggering their dependencies and cascading re-renders.
// ❌ BAD: Causes render cascade (destructuring subscribes to entire store)
const { currentFile, isDirty, saveFile } = useEditorStore()
const handleSave = useCallback(() => {
if (currentFile && isDirty) {
void saveFile()
}
}, [currentFile, isDirty, saveFile]) // ← Re-creates on every keystroke!
// ✅ GOOD: No cascade (getState pattern)
const setEditorContent = useEditorStore(state => state.setEditorContent)
const handleSave = useCallback(() => {
const { currentFile, isDirty, saveFile } = useEditorStore.getState()
if (currentFile && isDirty) {
void saveFile()
}
}, []) // ← Stable dependency array- In useCallback dependencies when you need current state but don't want re-renders
- In event handlers for accessing latest state without subscriptions
- In useEffect with empty dependencies when you need current state on mount only
- In async operations when state might change during execution
// Editor component with optimal subscriptions
const Editor = () => {
// Only subscribe to what triggers UI updates
const content = useEditorStore(state => state.editorContent)
// Handler needs current file but shouldn't re-render when it changes
const handleSave = useCallback(() => {
const { currentFile, isDirty, saveFile } = useEditorStore.getState()
if (currentFile && isDirty) {
void saveFile()
}
}, []) // Stable!
const handleChange = useCallback((newContent: string) => {
useEditorStore.getState().setEditorContent(newContent)
}, []) // Stable!
return <CodeMirror value={content} onChange={handleChange} />
}// ❌ BAD: Destructuring subscribes to entire store
const { currentFile } = useEditorStore()
// ✅ BETTER: Selector syntax creates granular subscription
const currentFile = useEditorStore(state => state.currentFile)
// ✅ BEST: Primitive selectors for even finer control
const hasCurrentFile = useEditorStore(state => !!state.currentFile)
const currentFileName = useEditorStore(state => state.currentFile?.name)
const fileCount = useEditorStore(state => state.files.length)CRITICAL: Destructuring vs selector syntax are NOT equivalent in Zustand:
// ❌ Subscribes to ENTIRE store - re-renders on ANY state change
const { currentFile } = useEditorStore()
// ✅ Creates granular subscription - re-renders only when currentFile changes
const currentFile = useEditorStore(state => state.currentFile)However, subscribing to objects/arrays can still cause unnecessary re-renders:
// ⚠️ PROBLEM: Object reference changes even when values don't
const currentFile = useEditorStore(state => state.currentFile)
// Re-renders whenever currentFile object is recreated, even if properties unchanged
// ✅ SOLUTION: Use useShallow for object/array subscriptions
import { useShallow } from 'zustand/react/shallow'
const currentFile = useEditorStore(useShallow(state => state.currentFile))
// Only re-renders when currentFile properties actually change// Derive complex data without re-renders
const sortedFileNames = useEditorStore(state =>
state.files
.filter(f => !f.draft)
.map(f => f.name)
.sort()
)
// Use useShallow for stable arrays/objects (Zustand v5)
import { useShallow } from 'zustand/react/shallow'
const fileNames = useEditorStore(
useShallow(state => state.files.map(f => f.name))
)
// Only re-renders if array contents change, not reference// ❌ BAD: Destructuring subscribes to entire store
const { loadProject } = useProjectStore()
useEffect(() => {
void loadProject()
}, [loadProject])
// ✅ GOOD: Direct getState() calls (no subscription)
useEffect(() => {
void useProjectStore.getState().loadProject()
}, [])
// ✅ ALSO GOOD: Selector for stable reference
const loadProject = useProjectStore(state => state.loadProject)
useEffect(() => {
void loadProject()
}, [loadProject]) // Store actions are stableFor stateful UI components (like react-resizable-panels), use CSS visibility instead of conditional rendering to preserve component state.
// ❌ BAD: Conditional rendering breaks stateful components
{frontmatterVisible ? (
<ResizablePanelGroup>
<ResizablePanel defaultSize={30}>
<FrontmatterPanel />
</ResizablePanel>
<ResizablePanel>
<Editor />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="w-full">
<Editor />
</div>
)}Why this is bad:
- Unmounts and remounts components on every toggle
- Loses panel sizes, scroll positions, internal state
- Triggers expensive initialization on every show/hide
// ✅ GOOD: CSS visibility preserves component tree
<ResizablePanelGroup>
<ResizablePanel
defaultSize={30}
className={cn(
'transition-all duration-200',
frontmatterVisible ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}
>
<FrontmatterPanel />
</ResizablePanel>
<ResizablePanel>
<Editor />
</ResizablePanel>
</ResizablePanelGroup>Use CSS Visibility (hidden, opacity-0, etc.) when:
- Component has internal state (panels, accordions, tabs)
- Component is expensive to initialize
- You need smooth transitions
- Toggling happens frequently
Use Conditional Rendering ({condition && <Component />}) when:
- Component is lightweight and stateless
- You want to completely avoid rendering cost
- Component has side effects you want to stop
- Memory usage is a concern
Note: React Compiler automatically prevents most unnecessary re-renders. Use React.memo only when profiling shows a specific need or for components with expensive render logic.
Use React.memo to break render cascades at component boundaries.
// ✅ GOOD: Breaks cascade propagation
const EditorAreaWithFrontmatter = React.memo(({
frontmatterPanelVisible
}: {
frontmatterPanelVisible: boolean
}) => {
// Component only re-renders when frontmatterPanelVisible changes
// Not affected by parent re-renders from unrelated state
return (
<div>
<Editor />
{frontmatterPanelVisible && <FrontmatterPanel />}
</div>
)
})// ❌ NOTE: React.memo doesn't help with internal store subscriptions
const Editor = React.memo(() => {
const content = useEditorStore(state => state.content) // Still triggers re-renders
// React.memo can't prevent re-renders from internal subscriptions
return <textarea value={content} />
})- Use at component boundaries where props change infrequently
- Combine with useCallback to ensure prop stability
- Don't overuse - measure before optimizing
- Custom comparison for complex props
const MemoizedComponent = React.memo(
MyComponent,
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return prevProps.id === nextProps.id &&
prevProps.name === nextProps.name
}
)Note: React Compiler handles most memoization automatically. Use manual memoization only when profiling shows a specific need.
Use memoization strategically for expensive computations that the compiler cannot optimize.
// Memoize expensive computations
const sortedFiles = useMemo(
() => files.sort((a, b) => compareDates(a.date, b.date)),
[files]
)
// Memoize complex derived state
const filesByCollection = useMemo(() => {
return collections.reduce((acc, collection) => {
acc[collection.name] = files.filter(f => f.collection === collection.name)
return acc
}, {} as Record<string, FileEntry[]>)
}, [collections, files])// ✅ Stable callbacks for child components (using getState pattern)
const handleChange = useCallback(
(value: string) => {
useEditorStore.getState().setEditorContent(value)
},
[] // Stable dependency array
)
// ✅ Stable callbacks with external dependencies
const handleSave = useCallback(
async (fileId: string) => {
await saveFileToServer(projectPath, fileId)
},
[projectPath] // Only recreate if projectPath changes
)// ❌ Premature optimization - simple computation
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])
// ✅ Just compute it directly
const fullName = `${firstName} ${lastName}`
// ❌ Memoizing everything
const handleClick = useCallback(() => setCount(count + 1), [count])
// ✅ Use updater function instead
const handleClick = () => setCount(c => c + 1)Defer heavy operations until needed.
// Lazy load heavy components
const PreferencesDialog = lazy(() => import('./PreferencesDialog'))
const CommandPalette = lazy(() => import('./CommandPalette'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
{showPreferences && <PreferencesDialog />}
</Suspense>
)
}// Load heavy dependencies only when needed
const loadMarkdownParser = async () => {
const { parseMarkdown } = await import('./heavy-parser')
return parseMarkdown
}
// Use in handler
const handleExport = async () => {
const parser = await loadMarkdownParser()
const parsed = parser(content)
// ... export logic
}For long lists, use virtualization:
import { useVirtualizer } from '@tanstack/react-virtual'
const FileList = ({ files }: { files: FileEntry[] }) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: files.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
})
return (
<div ref={parentRef} className="h-full overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
>
<FileItem file={files[item.index]} />
</div>
))}
</div>
</div>
)
}Critical for editor performance and preventing excessive operations.
// In store
let timeoutId: ReturnType<typeof setTimeout> | null = null
scheduleAutoSave: () => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
const { currentFile, isDirty } = get()
if (currentFile && isDirty) {
void get().saveFile()
}
}, 2000)
}import { useDebouncedValue } from '@/hooks/useDebouncedValue'
const SearchInput = () => {
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
// Only trigger expensive search when debounced value changes
const results = useSearchQuery(debouncedSearch)
return <input value={search} onChange={e => setSearch(e.target.value)} />
}import { useEffect, useState } from 'react'
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}Specific patterns required when using React Compiler.
CRITICAL: Async effects must use cancelled flags to prevent stale state updates.
// ✅ CORRECT: Cancelled flag prevents stale updates
useEffect(() => {
let cancelled = false
const loadData = async () => {
const data = await fetchData()
if (!cancelled) {
setData(data)
}
}
void loadData()
// Only set cancelled flag - keep state for smooth UX
return () => {
cancelled = true
}
}, [dependency])
// ❌ WRONG: No cancelled flag allows stale updates
useEffect(() => {
const loadData = async () => {
const data = await fetchData()
setData(data) // May update after unmount!
}
void loadData()
}, [dependency])For optimal UX, cleanup should only set the cancelled flag, not reset state:
// ✅ GOOD: Preserves state for smooth transitions
useEffect(() => {
const url = hoveredImage?.url
if (!url) return
let cancelled = false
const loadImage = async () => {
const imageUrl = await resolveImagePath(url)
if (!cancelled) {
setImageUrl(imageUrl)
}
}
void loadImage()
// Only cancel - keep imageUrl cached to prevent flicker
return () => {
cancelled = true
}
}, [hoveredImage?.url])
// ❌ BAD: Resetting state causes flicker
useEffect(() => {
// ... load logic ...
return () => {
cancelled = true
setImageUrl(null) // ❌ Breaks caching, causes flicker
}
}, [dependency])Use useState with initializer function for values from impure functions (e.g., Math.random()):
// ❌ WRONG: useMemo with impure function
const width = useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
// ✅ CORRECT: useState with initializer
const [width] = useState(
() => `${Math.floor(Math.random() * 40) + 50}%`
)Prefer computing values during render instead of useEffect + setState:
// ❌ WRONG: useEffect to derive state
const [preferencesVersion, setPreferencesVersion] = useState('...')
useEffect(() => {
if (globalSettings?.version) {
setPreferencesVersion(String(globalSettings.version))
}
}, [globalSettings?.version])
// ✅ CORRECT: Derive directly
const preferencesVersion = globalSettings?.version
? String(globalSettings.version)
: '...'For legitimate transition-based state updates in effects, document why and use proper guards:
// ✅ CORRECT: Documented transition-based setState
const previousIsRenamingRef = useRef(isRenaming)
useEffect(() => {
// Only run on false -> true transition
if (isRenaming && !previousIsRenamingRef.current) {
// Safe to setState here: guarded condition ensures this only runs on the
// false->true transition, preventing cascading renders. This is a standard
// React pattern for synchronizing derived state on mode transitions.
// eslint-disable-next-line react-hooks/set-state-in-effect
setRenameValue(fullName)
renameInitializedRef.current = false
}
previousIsRenamingRef.current = isRenaming
}, [isRenaming, fullName])When possible, move conditional logic from effects to return statements:
// ❌ LESS OPTIMAL: Clearing state in effect
useEffect(() => {
if (!isAltPressed) {
setHoveredImage(null)
}
}, [isAltPressed])
return hoveredImage
// ✅ BETTER: Conditional logic in return
return isAltPressed ? hoveredImage : null// ❌ BAD: Re-renders on every keystroke
const { editorContent } = useEditorStore()
// ✅ GOOD: Only subscribe in the editor itself
const EditorWrapper = () => {
const content = useEditorStore(state => state.editorContent)
return <Editor value={content} />
}
const Sidebar = () => {
// ✅ Doesn't subscribe to content, won't re-render
return <FileList />
}// ❌ BAD: Object reference changes trigger unnecessary re-renders
const file = useEditorStore(state => state.currentFile)
useEffect(() => {
console.log(file?.name)
}, [file]) // Triggers on every file object change, even if values unchanged
// ✅ BETTER: Use useShallow for objects
import { useShallow } from 'zustand/react/shallow'
const file = useEditorStore(useShallow(state => state.currentFile))
useEffect(() => {
console.log(file?.name)
}, [file]) // Only triggers when file properties change
// ✅ BEST: Subscribe to specific property
const fileName = useEditorStore(state => state.currentFile?.name)
useEffect(() => {
console.log(fileName)
}, [fileName]) // Only triggers when name changes// ❌ BAD: Loses state on every toggle
{visible && <StatefulComponent />}
// ✅ GOOD: Preserve state with CSS
<StatefulComponent className={visible ? '' : 'hidden'} />// ❌ BAD: Destructuring subscribes to entire store
const { saveFile } = useEditorStore()
useEffect(() => {
void saveFile()
}, [saveFile])
// ✅ GOOD: Direct getState() call (no subscription)
useEffect(() => {
void useEditorStore.getState().saveFile()
}, [])
// ✅ ALSO GOOD: Selector for stable reference
const saveFile = useEditorStore(state => state.saveFile)
useEffect(() => {
void saveFile()
}, [saveFile]) // Store actions are stable// ❌ BAD: Destructuring subscribes to ENTIRE store
const { files, currentFile, isDirty } = useEditorStore()
// Component re-renders on ANY editorStore change
// ✅ GOOD: Selector syntax creates granular subscriptions
const files = useEditorStore(state => state.files)
const currentFile = useEditorStore(state => state.currentFile)
const isDirty = useEditorStore(state => state.isDirty)
// Component only re-renders when these specific values change
// ✅ BEST: Add useShallow for objects/arrays
import { useShallow } from 'zustand/react/shallow'
const files = useEditorStore(useShallow(state => state.files))
const currentFile = useEditorStore(useShallow(state => state.currentFile))
const isDirty = useEditorStore(state => state.isDirty) // Primitive, no shallow neededAdd temporary render tracking during development:
// Temporary debugging only - remove before production
const renderCountRef = useRef(0)
renderCountRef.current++
useEffect(() => {
console.log(`[ComponentName] RENDER #${renderCountRef.current}`)
})IMPORTANT: Always remove render tracking after debugging.
Before considering performance work complete:
- Monitor component render counts during typical interactions
- Test with sidebars in different states (open/closed)
- Verify auto-save works under all conditions
- Use React DevTools Profiler to identify unnecessary re-renders
- Ensure editor renders only once per actual content change
- Test typing performance (should feel instant, no lag)
- Verify panel resizing doesn't cause unnecessary re-renders
- Check that closing files doesn't leave memory leaks
- Profile with 100+ files in file list
- Test with very large markdown files (10,000+ lines)
- Install React DevTools browser extension
- Open Profiler tab in DevTools
- Start recording before performing action
- Perform action (type, toggle sidebar, etc.)
- Stop recording and analyze
- Look for:
- Components rendering multiple times
- Long render times
- Unexpected cascading renders
- Components rendering when they shouldn't
Acceptable performance targets:
- Keystroke to render: < 16ms (60fps)
- Auto-save trigger: 2000ms after last keystroke
- File open: < 100ms
- Sidebar toggle: < 50ms
- Panel resize: 60fps during drag
For complex state synchronization:
// Subscribe to specific state changes
useEffect(() => {
const unsubscribe = useEditorStore.subscribe(
state => state.currentFile,
(currentFile, prevFile) => {
if (currentFile?.id !== prevFile?.id) {
// File changed, do something
console.log('File changed:', currentFile?.name)
}
}
)
return unsubscribe
}, [])// Create computed values in store
const useEditorStore = create<EditorState>((set, get) => ({
// ... state
// Computed getter
get hasUnsavedChanges() {
return get().isDirty && !!get().currentFile
},
// Computed action
canSave: () => {
const { currentFile, isDirty } = get()
return isDirty && !!currentFile
}
}))// Batch multiple state updates
const updateMultipleFields = (updates: Partial<EditorState>) => {
set(state => ({
...state,
...updates,
// Single render for all changes
}))
}Remember: Performance optimization is about measuring, not guessing. Use React DevTools Profiler to identify actual bottlenecks before optimizing.