Frontend

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.

Stop Using useMemo Wrong: A Practical Guide to React 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:

  1. Dependency array works differently than you expect

  2. Not everything should be memoized (sometimes it hurts performance)

  3. Object comparison doesn't work like primitives

  4. 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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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 expensive

  • Nested 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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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

JSX
// 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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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:

JSX
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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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

JSX
// 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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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

TEXT
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

JSX
/* ================================================ * ❌ 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

JSX
/* ================================================ * 💡 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

JSX
// 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:

  1. Dependency array needs values that React can compare

  2. Not everything should be memoized - only expensive operations

  3. Functions are objects - need special strategies

  4. Smart memoization saves resources

  5. Chained calculations avoid total reprocessing

  6. 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:

  1. Add console.time() to calculations you think are expensive

  2. Measure if they really take > 1ms

  3. Apply useMemo only to those that are worth it

  4. Use 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

  1. React Profiler DevTools - Official tool to measure performance: https://react.dev/reference/react/Profiler

  2. Web Performance APIs - W3C specification for performance measurement: https://www.w3.org/webperf/

  3. React Concurrent Features RFC - Official technical documentation on concurrent features: https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md