Skip to main content
Saved

Memoization

Category Performance

Level Advanced

Examples

React, Vue, Svelte, Native web

Cache expensive computation results to avoid recalculating with identical inputs.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Memoization
  5. Callback Memoization
  6. Component Memoization
  7. LRU Cache for Limited Memory
  8. Selector Pattern
  9. Fibonacci with Memoization
  10. Benefits
  11. Tradeoffs
  12. Summary
  13. See also

Memoization

Problem

Your component filters 10,000 products, and every time the parent re-renders for any reason, even if the products array hasn’t changed, you run that expensive filter again. Typing lags, and scrolling stutters as users move through the page.

I’ve watched performance profiles where a simple sort ran 47 times during a single user interaction because unrelated state changes triggered re-renders, producing the same sorted result 47 times.

The problem extends to callbacks too: every inline onClick={() => handleClick(id)} creates a new function reference, triggering re-renders in any memoized children that receive it. Your carefully optimized virtualized list just re-rendered every visible item because of a function that does exactly the same thing as before.

Solution

Cache the results of expensive computations so that when inputs haven’t changed, you return the cached value instead of recalculating.

In React, useMemo caches computed values until dependencies change. Vue’s computed properties handle caching automatically. Svelte’s $derived rune does the same, recalculating only when the state it reads changes. For callbacks passed to children, useCallback creates stable references that don’t trigger unnecessary re-renders. If your project uses the React Compiler (stable since October 2025), it adds this memoization for you at build time, so hand-written useMemo and useCallback are mostly for code the compiler skips.

My advice is to avoid memoizing everything. Memoization has overhead from dependency checking and cache management, and that can take longer than simply recalculating a cheap operation. Profiling first tells you whether you have a real performance problem. That sort on 10,000 items is a good candidate, while adding two numbers together isn’t worth caching.

Example

The concept is the same across frameworks: check if inputs changed, return cached result if they didn’t.

Basic Memoization

function ProductList({ products, category, sortBy }) {
  // Only recomputes when products or category change
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === category);
  }, [products, category]);

  // Chain memoizations for multi-step transformations
  const sortedProducts = useMemo(() => {
    return [...filteredProducts].sort((a, b) =>
      a[sortBy] > b[sortBy] ? 1 : -1
    );
  }, [filteredProducts, sortBy]);

  return (
    <ul>
      {sortedProducts.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Callback Memoization

When passing callbacks to memoized children, stable references prevent unnecessary re-renders:

function TodoList({ todos, setTodos }) {
  // Stable reference prevents child re-renders
  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, [setTodos]);

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
      ))}
    </ul>
  );
}

// React.memo skips re-render if props unchanged
const TodoItem = React.memo(({ todo, onToggle }) => (
  <li onClick={() => onToggle(todo.id)}>{todo.text}</li>
));

Component Memoization

Wrapping entire components prevents re-renders when their props haven’t changed:

// Wrap component to prevent re-renders when props unchanged
const ExpensiveComponent = React.memo(({ data }) => {
  const processedData = data.map(item => ({
    ...item,
    computed: expensiveCalculation(item)
  }));

  return processedData.map(item => <div key={item.id}>{item.computed}</div>);
});

// Custom comparison for complex props
const CustomCompare = React.memo(
  ({ data, config }) => { /* ... */ },
  (prev, next) => prev.data.length === next.data.length && prev.config.id === next.config.id
);

LRU Cache for Limited Memory

For unbounded input spaces, an LRU cache evicts the least recently used entries so the cache doesn’t keep growing:

class LRUCache {
  constructor(maxSize = 100) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }

  get(key) {
    if (!this.cache.has(key)) return undefined;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value); // Move to end (most recent)
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);
    if (this.cache.size > this.maxSize) {
      this.cache.delete(this.cache.keys().next().value); // Evict oldest
    }
  }
}

Selector Pattern

Redux selectors with reselect create memoized derived state that only recomputes when inputs change:

import { createSelector } from 'reselect';

const getProducts = state => state.products;
const getCategory = state => state.category;

// Memoized selector - only recomputes when inputs change
const getFilteredProducts = createSelector(
  [getProducts, getCategory],
  (products, category) => products.filter(p => p.category === category)
);

// Chained selectors
const getSortedProducts = createSelector(
  [getFilteredProducts, state => state.sortBy],
  (filtered, sortBy) => [...filtered].sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1)
);

Fibonacci with Memoization

This is the classic example. Without the cache, every call to fib(n) recomputes both smaller values from scratch, so the same calls repeat over and over. With the cache, each value from 0 to n is computed once and then reused:

// Each fib(n) is computed once, then read from the cache
const fibonacci = (() => {
  const cache = {};
  return function fib(n) {
    if (n in cache) return cache[n];
    if (n <= 1) return n;
    return cache[n] = fib(n - 1) + fib(n - 2);
  };
})();

fibonacci(50); // 12586269025, after about 50 additions instead of the uncached version's 40 billion or so calls

Benefits

  • Expensive operations only run when data changes, so that sort on 10,000 items stops running on every render.
  • Search boxes, scrolling, and other frequently updated parts of the UI lag less.
  • Stable callback references prevent cascading re-renders in memoized child components.
  • Complex derived state becomes practical since aggregates and transformations are cached automatically.
  • Mobile devices do less wasted CPU work, which helps battery life.

Tradeoffs

  • Memoization trades memory for CPU time, because every cached value lives in memory until its dependencies change or the component unmounts.
  • For cheap operations, memoization can make things slower rather than faster, because comparing dependencies and managing the cache can take longer than simply recalculating.
  • Dependency arrays in React are easy to get wrong, and forgetting a dependency results in stale cached values while including an unstable reference means you get no effective caching at all.
  • Shallow comparison can’t detect nested changes, so if user.name changes but the user object reference stays the same, memoization will miss the update.
  • Debugging memoized code is different, because your breakpoints only hit sometimes: cache hits skip the function body and return the cached value directly.
  • Custom comparison functions in React.memo are easy to get wrong, and a wrong one means you either re-render too much and waste work, or re-render too little and display stale data.
  • It’s easy to over-memoize by wrapping everything in useMemo “just in case,” adding complexity and overhead without helping performance.
  • Only pure functions should be memoized, because if your function has side effects, those effects will either be skipped when the cache is hit or duplicated when dependencies change unexpectedly, both of which lead to subtle bugs.

Summary

Memoization caches expensive computation results so repeated calls with the same inputs return instantly from cache instead of recalculating. It suits computationally heavy operations that get called frequently with the same arguments, and profiling first tells you whether you have a performance problem at all. Caching has its own overhead, and memoizing before you’ve measured can make code slower and harder to follow with nothing gained.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.