Lesson 17·M2 Data & Functions
Memoization
OpenLearning Objectives
By the end of this lesson you should be able to:
- Implement
memoizefrom scratch withMap, without looking it up - Explain the trade-offs: what memoization can and cannot cache
- Say precisely how
useMemoandReact.memodiffer from a hand-rolled cache - Diagnose when a memoized function looks broken because of stale data
One-line Definition
Memoization stores the result of a function call keyed by its arguments, and returns the stored value instead of recomputing.
const memoize = (fn) => {
const cache = new Map();
return (arg) => {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
};
let calls = 0;
const double = memoize((n) => { calls += 1; return n * 2; });
double(4); double(4); double(4);
console.log(double(4), 'calls:', calls);
Output:
8 calls: 1
Three calls, one computation. That's the entire mechanism.
Mental Model
Memoization is a cheat sheet. The first time you see f(4) you do the work and write
the answer down. The second time you skip the work and read the note.
f(4): not in cache → compute → store → 8
f(4): in cache → return 8 (no compute)
f(5): not in cache → compute → store → 10
The cache is the memo — a Map from input to output that lives inside a closure (Lesson 5)
so nothing outside can see or corrupt it.
Visual Flow
memoize(fn)
┌───────────────────────────────────┐
│ cache = Map {} ← private │
│ │
│ f(4) → in cache? no │
│ → result = fn(4) = 8 │
│ → cache.set(4, 8) │
│ → return 8 │
│ │
│ f(4) → in cache? YES → return 8 (fn never runs)
└───────────────────────────────────┘
Same input, same lookup path. The pure function (Lesson 14) runs only on the misses.
How It Works
The classic implementation, with a Map keyed by argument:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
const slowSquare = memoize((n) => {
console.log('computing', n);
return n * n;
});
console.log(slowSquare(9));
console.log(slowSquare(9));
console.log(slowSquare(10));
Output:
computing 9
81
81
computing 10
100
computing 9 prints once. The second slowSquare(9) is a cache hit, so the body never runs.
Note
The single-argument shortcut keeps primitive keys exact. For multiple arguments, stringify
for a stable key. Real libraries like lodash.memoize accept a resolver function instead
— you supply the key logic, the wrapper supplies the cache.
Real Project Usage
| Where | What gets memoized |
|---|---|
React useMemo | Expensive derived values inside a render |
React.memo | Whole components — skips re-render when props are equal |
| Redux selectors | Derived state recomputed only when inputs change |
| Server caches | Query results keyed by request parameters |
| Fibonacci / DP | Classic interview demonstration of exponential → linear |
fetch wrappers | Repeated identical requests answered from memory |
Interview Explanation
Memoization caches a function's results by its arguments, so repeated calls with the same input skip the computation. It works only for pure functions, and the key has to be a stable representation of the input — primitives work directly, objects need a stringified or resolved key.
React's
useMemois the same idea, but the cache is thrown away every render and keyed by the dependency array rather than by the arguments.
Senior-Level Insights
- "Pure functions only" isn't ceremony — it's correctness. If the function reads non-deterministic state (the clock, a counter, a global), caching a stale result is a bug you shipped. Name this unprompted and you sound like you've been burned by it.
- Reference equality is the React story.
useMemokeys on the dependency array, and arrays/objects compare by reference (Lesson 6), so a "same" input that is a new object always misses. This is whyuseMemodeps are compared byObject.is. - Memory is the real cost. An unbounded cache can hold onto data forever. A senior
answer mentions size limits, LRU eviction, or a
cache.clear()for long-lived caches. - Memoization doesn't speed up first calls — it converts repeated work into a lookup and pays memory for it. The trade is worth it exactly when the recomputation is expensive and the inputs repeat.
Common Mistakes
Mistake 1 — caching a non-pure function.
let now = () => Date.now();
// a memoized "now" returns the FIRST call's timestamp forever
Mistake 2 — object arguments without a key.
const memoize = (fn) => {
const cache = new Map();
return (arg) => {
if (cache.has(arg)) return cache.get(arg); // Map keys objects by REFERENCE
const result = fn(arg);
cache.set(arg, result);
return result;
};
};
const f = memoize((o) => o.total * 2);
console.log(f({ total: 5 }));
console.log(f({ total: 5 })); // SAME data, DIFFERENT object → miss
Output:
10
10
Both calls printed the same number — but only because the computation was trivial. The
second { total: 5 } is a different object, so cache.has said "no" and recomputed.
With an expensive body, that's a silent performance leak. Stringify the key or pass a
resolver.
Mistake 3 — expecting useMemo to cache across renders. Its cache lives for one render
and is rebuilt every time; it's not a persistent memo. (More below.)
Mistake 4 — memoizing what should never be memoized: random values, timestamps, anything reading user input that changes identity. If the answer can change without the key changing, you've stored a lie.
Best Practices
✅ Memoize pure functions with stable, repeatable outputs
✅ Use Map for the cache — Map.has/Map.get handle any key type exactly
✅ Key by value, not by object reference, when callers may pass fresh objects
✅ Bound the cache (clear it, or evict) when entries live long or grow large
✅ In React, memoize derived data (useMemo) and components (React.memo) — not everything
❌ Don't memoize timestamps, randomness, or anything side-effectful
❌ Don't cache objects/arrays if callers expect fresh references — the same reference coming back out is a mutation trap
Interview Questions
Q1. What is memoization?
Storing a function's results keyed by its arguments, so identical calls are answered from the cache instead of recomputed. It turns repeated work into a hash lookup.
Q2. Why does it require pure functions?
Because the cache assumes the same input always produces the same output. If the function reads mutable or non-deterministic state, a stored result becomes stale — the memo is lying about the current answer.
Q3. Implement memoize.
A closure holding a
Map. Single-argument primitives key directly; multi-argument calls stringify the arguments (or use a resolver) to build the key:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
Senior follow-up: How is useMemo different?
useMemo(fn, deps)caches for a single render and invalidates when the dependency array changes — it's a render-scoped memo, not a persistent one.React.memois different again: it's a whole component that skips re-rendering when props are shallowly equal. Hand-rolled memoization is a cache that lives for the lifetime of the closure.
Senior follow-up: What can go wrong with object keys?
Two objects with identical contents are still different
Mapkeys, so the cache misses and recomputes. If the caller passes fresh objects every time, the cache never helps — stringify the key or accept a resolver. And because keys are held by reference, the cache also keeps the objects alive, which matters if they're large.
Follow-up Questions
What's the downside of memoization?
Memory. Every stored result is a retained object, and keys are retained too. An unbounded cache can grow without limit — the answer is eviction or a bounded size.
How would you memoize a multi-argument function?
Build a composite key —
JSON.stringify(args), or join the arguments with a delimiter, or let the caller supply a resolver that returns a stable key for any argument list.
When is memoization useless?
When inputs rarely repeat, when the computation is already cheap, or when the function isn't pure. In those cases you pay cache overhead for no gain.
Comparison Table
Hand-rolled memoize | useMemo | React.memo | |
|---|---|---|---|
| What is cached | A function's return value | A computed value | A rendered component |
| Key | Arguments (or resolver) | Dependency array | Props (shallow) |
| Cache lifetime | Lifetime of the closure | One render | Until props change |
| Use case | Repeated expensive calls | Derived data in renders | Skipping re-renders |
| Requires purity | ✅ | ✅ (inside render) | ✅ (component) |
Code Example
The classic demonstration — Fibonacci goes from exponential to linear. The helper from section 4 is included so the block runs standalone:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
let plainCalls = 0;
let memoCalls = 0;
function fib(n) {
plainCalls += 1;
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
const memoizedFib = memoize(function fibMemo(n) {
memoCalls += 1;
return n <= 1 ? n : memoizedFib(n - 1) + memoizedFib(n - 2);
});
console.log(fib(20), 'plain calls:', plainCalls);
console.log(memoizedFib(20), 'memoized calls:', memoCalls);
Output:
6765 plain calls: 21891
6765 memoized calls: 21
21 calls versus 21,891 — each n computed once. The memoized version memoizes its own
recursive calls, which is why it collapses. (21 is the count of distinct n values the
memoized body actually evaluates — the plain version re-evaluates the same n hundreds of
times.)
Performance Notes
- Memoization matters when the computation is expensive and the inputs repeat — parsing a large payload, deriving filtered lists, recursion with overlapping subproblems.
- It doesn't help cheap or one-shot work; the
Maplookup costs more than the work it skips. - First call is always a miss. If every input is unique, you've added cache overhead to the whole pipeline for nothing.
- Reference-typed keys defeat the cache silently — the code still returns the right answer, it just never hits. Profile, don't guess.
Debugging Scenarios
Scenario 1 — "My memoized function returns stale data."
The same memoize helper, self-contained so the block runs standalone:
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
};
let n = 0;
const read = memoize(() => n); // reads mutable state — not pure
console.log(read()); // 0 — first call, computed and stored
n = 42;
console.log(read()); // 0 — STALE: cached answer, `n` never re-read
Output:
0
0
The first call cached the answer while n was 0, and every later call returns that stored
value no matter how n changes. The function wasn't pure. Pass the changing value in as an
argument so it becomes part of the key.
Scenario 2 — "The cache never hits; DevTools shows it growing forever."
Fresh objects are arriving as keys. new Map() compares by reference, so structurally
identical inputs miss. Inspect the keys: if every call has a new object identity, stringify
the key or switch to a resolver-based memo.
Scenario 3 — "Clearing doesn't clear."
const cache = new Map();
// a helper closes over this SAME cache for different functions
Two memoized functions sharing one cache can collide when keys overlap. Keep one Map per
memoized function, inside its own closure.
Quick Revision Notes
- Memoize = cache results by input; return cached value on repeat calls
- Only pure functions (Lesson 14) can be safely memoized
Mapkeys primitives exactly; objects key by reference — stringify or resolveuseMemois render-scoped;React.memois per-component; both are reference-sensitive- First call is always a miss; the win is repeated inputs with expensive work
- Memory is the trade-off — bound or evict long-lived caches
Cheat Sheet
memoize(fn)
cache = Map
key = single arg → args[0]
= multiple → JSON.stringify(args)
hit → cache.get(key)
miss → cache.set(key, fn(...args))
useMemo(fn, deps) → cache lives one render, keyed by deps (Object.is)
React.memo(Comp) → skip re-render when props shallowly equal
fib memoized → 21,891 calls → 21 calls (n=20)Key Takeaways
Recap
- Memoization stores results keyed by arguments and skips recomputation on repeat calls
- It's correct only for pure functions — caching side effects caches lies
- The cache is a closure-held
Map(Lesson 5); object keys compare by reference, so identical-but-distinct objects always miss useMemocaches for one render by dependency array;React.memocaches a component by props- The trade is memory for speed — bound the cache when it lives long
- It shines on expensive, repeating work: Fibonacci drops from 21,891 calls to 21
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/5Write memoize from memory, then explain what the closure is for.
Why is memoizing a non-pure function a correctness bug, not just a style question?
Why do two identical objects miss the cache when used as Map keys?
In one sentence each: memoize vs useMemo vs React.memo.
When does memoization make things worse?
What's Next
Lesson 18 — Debounce & Throttle. The most-requested "implement this on a whiteboard" function in frontend interviews — and the answer is timing, not caching.