When dealing with performance issues in React, developers often reach for complex solutions like useMemo
, useCallback
, or React.memo
. However, simple composition patterns can often solve performance problems more elegantly.
The Problem: Unnecessary Re-renders
Consider this common scenario:
const App = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<ExpensiveComponent />
<ComplexDataGrid />
<button onClick={() => setIsModalOpen(true)}>
Open Modal
</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
);
}
Every time the modal opens or closes, both ExpensiveComponent
and ComplexDataGrid
will re-render unnecessarily. This happens because state changes trigger re-renders for the component and all its children.
Solution 1: Moving State Down
Instead of managing modal state in the parent, we can isolate it in its own component:
// ✅ Good: Modal state is isolated
const ModalButton = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
Open Modal
</button>
{isOpen && <Modal onClose={() => setIsOpen(false)} />}
</>
);
}
// Now the expensive components won't re-render when modal state changes
const App = () => {
return (
<div>
<ExpensiveComponent />
<ComplexDataGrid />
<ModalButton />
</div>
);
}
Solution 2: Children as Props
Sometimes you need to share data between components but don’t want to trigger unnecessary re-renders. The children
prop is perfect for this:
const Layout = ({ children }) => {
const [sidebarWidth, setSidebarWidth] = useState(200);
return (
<div style={{ display: 'flex' }}>
<Sidebar width={sidebarWidth} onResize={setSidebarWidth} />
<main>
{children}
</main>
</div>
);
}
// Changes to sidebar width won't cause content to re-render
const App = () => {
return (
<Layout>
<ExpensiveComponent />
<ComplexDataGrid />
</Layout>
);
}
Solution 3: Component Splitting
When dealing with forms, split components based on which parts need to update together:
const ExpensivePreview = ({ data }) => {
// Heavy calculations here
return <div>{/* Complex rendering */}</div>;
}
const Form = () => {
const [formData, setFormData] = useState(initialData);
return (
<div>
<FormInputs
data={formData}
onChange={setFormData}
/>
<ExpensivePreview data={formData} />
</div>
);
}
// Only form inputs re-render on every keystroke
const FormInputs = ({ data, onChange }) => {
return (
<div>
<input
value={data.name}
onChange={e => onChange({ ...data, name: e.target.value })}
/>
{/* More inputs */}
</div>
);
}
Real World Example: DataGrid with Filters
Here’s a practical example combining these patterns:
const DataGridWithFilters = () => {
return (
<div>
<FiltersSection />
<DataGrid />
</div>
);
}
const FiltersSection = () => {
const [filters, setFilters] = useState(initialFilters);
return (
<FilterContext.Provider value={{ filters, setFilters }}>
<div>
<QuickFilters />
<AdvancedFilters />
</div>
</FilterContext.Provider>
);
}
const DataGrid = () => {
const { filters } = useFilterContext();
return (
<div>
{/* Complex grid rendering */}
</div>
);
}
This structure ensures that:
- Filter changes only re-render filter components
- The grid only re-renders when filters actually change
- No need for complex memoization
Key Benefits
- More maintainable code
- Better performance by default
- Less risk of memoization-related bugs
- Clearer data flow
- Easier testing
Composition patterns often eliminate the need for complex optimization techniques while making your code more maintainable.