Stop Using useMemo Wrong: A Practical Guide to React Performance
Master React useMemo with this guide. Learn when to memoize, how to fix dependency array bugs, and why over-optimization can hurt your performance.

You followed the documentation perfectly: you wrapped your expensive calculations in useMemo, expecting a snappy interface. Yet, your React app still stutters. It is a frustrating paradox that leaves many developers wondering why their performance optimizations are falling flat.
Then you add it in a few places and... nothing changes. Or worse, your app gets slower. You start wondering if you're doing something wrong or if useMemo is just another hook everyone talks about but no one explains properly.
I spent months using useMemo the wrong way. The React documentation explains WHAT it does, but it doesn't explain WHEN to use it, WHY it sometimes doesn't work, and how to avoid the mistakes everyone makes.
Here is the clear explanation I wanted when I started.
Why useMemo Seems So Confusing
The problem isn't you. It's that useMemo has invisible nuances that the documentation doesn't explain well:
Dependency array works differently than you expect
Not everything should be memoized (sometimes it hurts performance)
Object comparison doesn't work like primitives
Execution timing can surprise you
Most tutorials show basic examples and skip these important parts. Result: you memorize syntax without understanding when you really need to use it.
Let's fix that now.
1. The Mystery of the Dependency Array (Why It Sometimes Doesn't Work)
The Simple Truth
React compares each dependency using Object.is(). This means:
Primitives (string, number, boolean): Compares by value ✅
Objects and arrays: Compares by reference in memory ❌
Why does this matter? Objects and arrays are recreated on every render, so even with identical content, React thinks it changed.
Let's See It in Practice
/* ================================================ * ❌ PROBLEMA: Por que isso não funciona? * * O objeto filters é RECRIADO a cada render. * Mesmo que os valores sejam iguais, a referência * na memória é diferente. * * Resultado: useMemo SEMPRE recalcula * ================================================ */function ListaProdutos({ filtros, produtos }) { const produtosFiltrados = useMemo(() => { console.log('🔄 Filtrando produtos...'); return produtos.filter(produto => filtros.categorias.includes(produto.categoria) && produto.preco >= filtros.precoMinimo ); }, [filtros, produtos]); // ❌ filtros muda toda hora return <Lista items={produtosFiltrados} />;}// Cada render cria um NOVO objeto filtros<ListaProdutos filtros={{ categorias: ['eletrônicos'], precoMinimo: 100 }} produtos={produtos} />What happens: Console shows "🔄 Filtering products..." on EVERY render, even when filters haven't actually changed.
✅ The Solution That Works
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Ao invés de passar objeto inteiro, extraímos * os valores primitivos (string, number). * * React consegue comparar esses valores corretamente * e só recalcula quando REALMENTE mudarem. * ================================================ */function ListaProdutos({ filtros, produtos }) { // Extrair valores primitivos const { categorias, precoMinimo } = filtros; const categoriasString = categorias.join(','); // String é primitivo const produtosFiltrados = useMemo(() => { console.log('🔄 Filtrando produtos...'); return produtos.filter(produto => categorias.includes(produto.categoria) && produto.preco >= precoMinimo ); }, [categoriasString, precoMinimo, produtos]); // ✅ Só primitivos return <Lista items={produtosFiltrados} />;}Now it works: Console only shows "🔄 Filtering products..." when categories or price REALLY change.
When Is This Useful
Search filters: Avoids reprocessing the entire list on every keystroke
Dashboards: Expensive calculations only run when data changes
Complex forms: Heavy validations don't lag the input
⚠️ Beware Of
Large arrays:
join()on 10,000 items it can be expensiveNested objects: Extracting all values gets complex
Performance trade-off: Sometimes it's better to recalculate than to juggle
2. The Mistake of Memoizing Everything (When NOT to use useMemo)
The Simple Truth
useMemo has a cost: it needs to compare dependencies and store the value in memory. If the operation is simple, the cost of useMemo is HIGHER than simply recalculating.
Rule of thumb: Only memoize if the operation takes longer than 1ms.
Let's See the Problem
/* ================================================ * ❌ PROBLEMA: Over-optimization * * Essas operações são MUITO rápidas (< 0.01ms). * useMemo está consumindo memória e fazendo comparações * desnecessárias. * * Resultado: App usa mais memória sem ganho real * ================================================ */function PerfilUsuario({ usuario, preferencias }) { const nomeUsuario = useMemo(() => usuario.nome, [usuario.nome]); // ❌ const idade = useMemo(() => 2024 - usuario.anoNascimento, [usuario.anoNascimento]); // ❌ const isAdmin = useMemo(() => usuario.role === 'admin', [usuario.role]); // ❌ // ✅ Esse sim vale a pena memoizar (operação cara) const estatisticasComplexas = useMemo(() => { return calcularAnaliseCompleta(usuario.atividade, preferencias.metricas); }, [usuario.atividade, preferencias.metricas]); return ( <div> {nomeUsuario} - {idade} anos - {isAdmin ? 'Admin' : 'User'} <GraficoEstatisticas dados={estatisticasComplexas} /> </div> );}What happens: App uses extra memory to store values that are instantaneous to calculate.
✅ The Right Way
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Operações baratas: sem useMemo * Operações caras: com useMemo * * Como saber? Se demora menos de 1ms, não memoize. * Use console.time() para medir. * ================================================ */function PerfilUsuario({ usuario, preferencias }) { // ✅ Operações simples: sem useMemo const nomeUsuario = usuario.nome; const idade = 2024 - usuario.anoNascimento; const isAdmin = usuario.role === 'admin'; // ✅ Operação cara: COM useMemo const estatisticasComplexas = useMemo(() => { console.time('Cálculo Estatísticas'); const resultado = calcularAnaliseCompleta(usuario.atividade, preferencias.metricas); console.timeEnd('Cálculo Estatísticas'); // Mostra tempo no console return resultado; }, [usuario.atividade, preferencias.metricas]); return ( <div> {nomeUsuario} - {idade} anos - {isAdmin ? 'Admin' : 'User'} <GraficoEstatisticas dados={estatisticasComplexas} /> </div> );}Result: App uses less memory and performance improves because there is no overhead of unnecessary comparisons.
How to Decide
✅ MEMOIZE when:
Processing an array with 1000+ items
Complex mathematical calculations
Heavy data transformations
Operations that take > 1ms
❌ DO NOT MEMOIZE when:
Accessing a simple property (
user.name)Basic math operations (
2024 - year)Simple comparisons (
role === 'admin')Concatenating short strings
Useful Tool
// Hook pra medir se vale a pena memoizarfunction useMemoWithProfiling(factory, deps, nome) { return useMemo(() => { const inicio = performance.now(); const resultado = factory(); const fim = performance.now(); console.log(`${nome}: ${(fim - inicio).toFixed(2)}ms`); return resultado; }, deps);}// Usoconst dados = useMemoWithProfiling( () => processarDadosGrandes(rawData), [rawData], 'Processar Dados');3. Functions That Always Change (The Callback Problem)
The Simple Truth
Functions are objects in JavaScript. Each render creates a NEW function, even if the code is identical.
This causes re-renders in child components that use React.memo.
Let's See the Problem
/* ================================================ * ❌ PROBLEMA: Novas funções em cada render * * Cada render cria NOVAS funções onToggle e onDelete. * Componentes TodoItem re-renderizam mesmo que * o todo não tenha mudado. * * Resultado: Lista com 100 items = 100 re-renders * ================================================ */function ListaTodos({ todos, onUpdate }) { return ( <div> {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} onToggle={() => onUpdate(todo.id, !todo.completo)} // ❌ Nova função onDelete={() => onUpdate(todo.id, null)} // ❌ Nova função /> ))} </div> );}// TodoItem está usando React.memo mas não adiantaconst TodoItem = React.memo(({ todo, onToggle, onDelete }) => { console.log(`Renderizando ${todo.id}`); return ( <div> <span>{todo.texto}</span> <button onClick={onToggle}>✓</button> <button onClick={onDelete}>🗑️</button> </div> );});What happens: Console shows all items being rendered, even if only one changed.
✅ The Solution
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Ao invés de criar funções novas, criamos uma * "fábrica de funções" memoizada. * * A fábrica retorna sempre a MESMA função para * cada todo.id, evitando re-renders desnecessários. * ================================================ */function ListaTodos({ todos, onUpdate }) { // Fábrica de funções memoizada const criarHandlerToggle = useMemo(() => (todoId, completo) => () => onUpdate(todoId, completo) , [onUpdate]); const criarHandlerDelete = useMemo(() => (todoId) => () => onUpdate(todoId, null) , [onUpdate]); return ( <div> {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} onToggle={criarHandlerToggle(todo.id, !todo.completo)} onDelete={criarHandlerDelete(todo.id)} /> ))} </div> );}const TodoItem = React.memo(({ todo, onToggle, onDelete }) => { console.log(`Renderizando ${todo.id}`); return ( <div> <span>{todo.texto}</span> <button onClick={onToggle}>✓</button> <button onClick={onDelete}>🗑️</button> </div> );});Now it works: Console only shows the item that actually changed being rendered.
When Is This Useful
Large lists: Grids with 100+ items
React.memo components: That need prop stability
Drag & Drop: Handlers need to be stable
Virtualization: react-window, react-virtualized
⚠️ Simpler Alternative
For most cases, useCallback is more direct:
const handleClick = useCallback((id) => { onUpdate(id);}, [onUpdate]);Use useMemo when you need to return something other than a function.
4. Smart Memoization (Only Enable When Necessary)
The Simple Truth
It makes no sense to memoize values of components that aren't even visible on the screen. This wastes memory.
Let's See the Problem
/* ================================================ * ❌ PROBLEMA: Memoizando mesmo invisível * * Component está escondido (display: none) mas * continua processando dados pesados. * * Resultado: Memória e CPU desperdiçados * ================================================ */function PainelDados({ dados, isVisivel }) { const dadosProcessados = useMemo(() => { console.log('🔄 Processando dados...'); return processamentoMuitoPesado(dados); }, [dados]); // Roda mesmo quando isVisivel = false if (!isVisivel) { return null; // Componente não renderiza mas já processou tudo } return <Grafico dados={dadosProcessados} />;}What happens: Console shows "🔄 Processing data..." even when the panel is hidden.
✅ The Solution
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Só processar quando realmente necessário: * - Componente está visível * - Dados são grandes (> 100 items) * * Se não precisa agora, não processa. * ================================================ */function PainelDados({ dados, isVisivel }) { const dadosGrandes = dados.length > 100; const deveMemoizar = isVisivel && dadosGrandes; const dadosProcessados = useMemo(() => { if (!deveMemoizar) { return processamentoMuitoPesado(dados); } console.log('🔄 Processando dados (memoizado)...'); return processamentoMuitoPesado(dados); }, deveMemoizar ? [dados] : []); // Array vazio = sempre recalcula if (!isVisivel) { return null; } return <Grafico dados={dadosProcessados} />;}Result: Processing only happens when the panel becomes visible.
More Practical Example
// Hook customizado pra memoização inteligentefunction useMemoizacaoInteligente(factory, deps, condicoes) { const { isVisivel = true, tamanhoMinimo = 0 } = condicoes; const deveMemoizar = isVisivel && (deps[0]?.length || 0) >= tamanhoMinimo; return useMemo(() => { if (!deveMemoizar) { return factory(); } return factory(); }, deveMemoizar ? deps : []);}// Usofunction PainelDados({ dados, isVisivel }) { const dadosProcessados = useMemoizacaoInteligente( () => processamentoMuitoPesado(dados), [dados], { isVisivel, tamanhoMinimo: 100 } ); if (!isVisivel) return null; return <Grafico dados={dadosProcessados} />;}When Is This Useful
Tabs/Modals: Only process the active tab
Infinite scroll: Only process visible items
Dashboards: Only calculate expanded widgets
Mobile: Save battery/CPU
5. Chained Calculations (How Not to Reprocess Everything)
The Simple Truth
If you have multiple calculations that depend on each other, you can split them into multiple useMemo. This way, if only one part changes, the others don't recalculate.
Let's See the Problem
/* ================================================ * ❌ PROBLEMA: Um useMemo fazendo tudo * * Se chartConfig mudar (coisa barata), TUDO * é recalculado: filtro caro, agregação cara, * e formatação barata. * * Resultado: Mudou cor do gráfico = recalcula tudo * ================================================ */function Dashboard({ dadosRaw, filtros, chartConfig }) { const dadosProntos = useMemo(() => { console.log('🔄 TUDO sendo recalculado...'); const filtrados = aplicarFiltros(dadosRaw, filtros); // Caro const agregados = agregarMetricas(filtrados); // Caro const formatados = formatarParaGrafico(agregados, chartConfig); // Barato return formatados; }, [dadosRaw, filtros, chartConfig]); // ❌ chartConfig força tudo return <Grafico dados={dadosProntos} />;}What happens: Changing the chart color reprocesses thousands of records.
✅ The Solution
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Separar em camadas: * 1. Operação mais cara (filtro) * 2. Operação cara (agregação) * 3. Operação barata (formatação) * * Cada uma só recalcula se SUA dependência mudar. * ================================================ */function Dashboard({ dadosRaw, filtros, chartConfig }) { // Camada 1: Mais cara, depende de dados + filtros const dadosFiltrados = useMemo(() => { console.log('🔄 Filtrando dados...'); return aplicarFiltros(dadosRaw, filtros); }, [dadosRaw, filtros]); // Camada 2: Cara, depende só dos dados filtrados const dadosAgregados = useMemo(() => { console.log('🔄 Agregando métricas...'); return agregarMetricas(dadosFiltrados); }, [dadosFiltrados]); // Camada 3: Barata, depende de agregados + config const dadosFormatados = useMemo(() => { console.log('🔄 Formatando para gráfico...'); return formatarParaGrafico(dadosAgregados, chartConfig); }, [dadosAgregados, chartConfig]); // ✅ Só reroda formatação return <Grafico dados={dadosFormatados} />;}Result: Changing the chart color only runs formatting (cheap). Filter and aggregation don't run again.
Visualization of What Happens
chartConfig muda:❌ Antes: Filtro → Agregação → Formatação (TUDO)✅ Depois: Formatação (SÓ ISSO)filtros muda:✅ Filtro → Agregação → Formatação (necessário)dadosRaw muda:✅ Filtro → Agregação → Formatação (necessário)When Is This Useful
Data pipelines: ETL, multiple transformations
Complex dashboards: Filters + calculations + formatting
Visualizations: Data → Scales → Rendering
Forms: Validation → Formatting → Submit
6. Asynchronous Operations (Avoid Duplicate Requests)
The Simple Truth
useMemo is synchronous, but you can use it to avoid starting the SAME asynchronous operation multiple times.
Let's See the Problem
/* ================================================ * ❌ PROBLEMA: Múltiplas requisições iguais * * Cada render pode disparar nova requisição, * mesmo que userId seja o mesmo. * * Resultado: API recebe 10 chamadas idênticas * ================================================ */function PerfilUsuario({ userId }) { const [userData, setUserData] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); buscarDadosUsuario(userId).then(dados => { setUserData(dados); setLoading(false); }); }, [userId]); // Re-roda toda vez if (loading) return <div>Carregando...</div>; return <div>{userData?.nome}</div>;}What happens: During development (StrictMode), React renders 2x, triggering 2 requests.
✅ The Solution
/* ================================================ * 💡 EXPLICAÇÃO SIMPLES: * * Criar hook que: * 1. Guarda resultado da requisição * 2. Só faz nova requisição se userId mudar * 3. Cancela requisição se componente desmontar * ================================================ */function useAsyncMemo(asyncFactory, deps) { const [state, setState] = useState({ data: null, loading: true, error: null }); // Memoiza a última combinação de deps vista const depsString = deps.map(d => JSON.stringify(d)).join(','); useEffect(() => { let cancelado = false; asyncFactory() .then(data => { if (!cancelado) { setState({ data, loading: false, error: null }); } }) .catch(error => { if (!cancelado) { setState({ data: null, loading: false, error }); } }); return () => { cancelado = true; // Cancela se componente desmontar }; }, [depsString]); return state;}// Usofunction PerfilUsuario({ userId }) { const { data: userData, loading, error } = useAsyncMemo( () => buscarDadosUsuario(userId), [userId] ); if (loading) return <div>Carregando...</div>; if (error) return <div>Erro: {error.message}</div>; return <div>{userData?.nome}</div>;}Result: Only one request per userId, even with re-renders.
Version With Cache
// Hook com cache de 5 minutosfunction useAsyncMemoComCache(asyncFactory, deps, tempoCacheMs = 5 * 60 * 1000) { const cache = useRef(new Map()); const [, forceUpdate] = useState(0); const cacheKey = JSON.stringify(deps); const cached = cache.current.get(cacheKey); const estaNovo = cached && (Date.now() - cached.timestamp < tempoCacheMs); useEffect(() => { if (estaNovo) return; // Usa cache let cancelado = false; cache.current.set(cacheKey, { data: cached?.data || null, loading: true, timestamp: Date.now() }); forceUpdate(n => n + 1); asyncFactory() .then(data => { if (!cancelado) { cache.current.set(cacheKey, { data, loading: false, timestamp: Date.now() }); forceUpdate(n => n + 1); } }) .catch(error => { if (!cancelado) { cache.current.set(cacheKey, { data: null, loading: false, error, timestamp: Date.now() }); forceUpdate(n => n + 1); } }); return () => { cancelado = true; }; }, [cacheKey, estaNovo]); return cache.current.get(cacheKey) || { data: null, loading: true };}// Usofunction PerfilUsuario({ userId }) { const { data, loading, error } = useAsyncMemoComCache( () => buscarDadosUsuario(userId), [userId], 5 * 60 * 1000 // 5 minutos de cache ); if (loading) return <div>Carregando...</div>; if (error) return <div>Erro: {error.message}</div>; return <div>{data?.nome}</div>;}When Is This Useful
User data: Profile, preferences
Static lists: Categories, countries
Settings: App settings, feature flags
Searches: Autocomplete, suggestions
⚠️ For Production
Use libraries like:
React Query: Cache, refetch, mutations
SWR: Stale-while-revalidate pattern
Redux Toolkit Query: Integrated with Redux
You can do it! 🎉
Now you know the truth about useMemo:
Dependency array needs values that React can compare
Not everything should be memoized - only expensive operations
Functions are objects - need special strategies
Smart memoization saves resources
Chained calculations avoid total reprocessing
Asynchronous operations need extra care
It's not about memorizing syntax. It's about understanding when and why to use each technique.
Next Step
Open one of your projects and:
Add
console.time()to calculations you think are expensiveMeasure if they really take > 1ms
Apply
useMemoonly to those that are worth itUse React DevTools Profiler to see the real difference
Any questions, the React community is there to help. Everyone has been confused by this! 💪
Reference Materials
React Profiler DevTools - Official tool to measure performance: https://react.dev/reference/react/Profiler
Web Performance APIs - W3C specification for performance measurement: https://www.w3.org/webperf/
React Concurrent Features RFC - Official technical documentation on concurrent features: https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md


