InterviewsVector
intermediateCommon5 min read · Updated Jul 18, 2026

Implement memoizeLast(): The Single-Slot Memoizer

Implement memoizeLast in JavaScript — one cached call, Object.is comparison, and why this exact contract is React's useMemo, memo, and reselect.


The problem

Implement memoizeLast(fn): cache only the previous call — same arguments as last time returns the cached result; different arguments recompute and replace the cache.

This looks like a stripped-down memoize, and mechanically it is — but the single-slot contract is its own idea, and it happens to be the exact semantics of React.useMemo, React.memo, and reselect selectors. Interviewers ask it because it's React's engine in fifteen lines; connect it explicitly and the question becomes a conversation you're leading.

Implementation

function memoizeLast(fn) {
  let lastThis;
  let lastArgs = null;   // null = "never called" — not an empty array
  let lastResult;
 
  return function (...args) {
    const hit =
      lastArgs !== null &&
      lastThis === this &&                       // a different receiver is a different call
      args.length === lastArgs.length &&
      args.every((arg, i) => Object.is(arg, lastArgs[i]));
 
    if (hit) return lastResult;
 
    lastResult = fn.apply(this, args);
    lastArgs = args;
    lastThis = this;
    return lastResult;
  };
}

Decisions worth narrating:

  • Object.is, not === — so a NaN argument can hit the cache (NaN === NaN is false and would force a pointless recompute every call). This is literally React's choice for hook deps — the whole story.
  • lastArgs = null as the never-called sentinel — guarding on "is lastResult set" breaks for functions that return undefined; same flag discipline as once.
  • Compare this tooa.compute(5) then b.compute(5) are different computations if fn reads this; skipping the check silently serves a's answer to b.
  • Shallow, by design — arguments compare by reference. memoLast({ x: 1 }) twice misses, because each literal is a new object. That's not a bug to fix with deep-equal — it's the contract, and it's the entire reason React demands stable references.

Verified behavior

let computes = 0;
const area = memoizeLast((w, h) => { computes++; return w * h; });
 
area(3, 4); // 12  computes=1
area(3, 4); // 12  computes=1 — hit
area(5, 4); // 20  computes=2 — replaced the slot
area(3, 4); // 12  computes=3 — the (3,4) entry is GONE; only ONE slot exists

That last line is the discriminating test: a full memoize would hit; memoizeLast recomputes. If your implementation hits there, you built a multi-entry cache and missed the point.

Why a one-entry cache is a feature

  • O(1) memory forever — no growth, no eviction policy, no LRU machinery; the cache cannot leak by construction.
  • It matches UI reality — renders are called repeatedly with the same props/state until something changes, then the old value is worthless. A history cache buys nothing; the previous call is the only entry with a realistic hit rate.
  • Staleness is impossible — one slot, always the latest; the invalidation problem deletes itself.

That trade — hit rate for bounded memory and zero invalidation — is the React rendering model:

// useMemo ≈ memoizeLast keyed on the deps array:
const sorted = useMemo(() => sortItems(items, order), [items, order]);
// deps compared element-wise with Object.is — exactly our `hit` check.
 
// React.memo ≈ memoizeLast over props (compared key-wise, shallowly).
// reselect selectors ≈ memoizeLast over input-selector outputs.

And the infamous pitfall drops out of the contract for free: an inline {} / [] / arrow prop is a new reference each render → the shallow check misses every time → React.memo does nothing. Explaining that via your own fifteen lines is the strongest possible answer to "why didn't memo help?"

Edge cases interviewers probe

  • The replaced-slot testA, B, A recomputes A (above); the interviewer is checking you built one slot, not two.
  • undefined returns — cached and served correctly thanks to the lastArgs sentinel.
  • NaN arguments — hit via Object.is; with === you'd recompute every render and never notice.
  • Mutated argument objectsarea(config) twice where config.w changed in between: reference-equal → stale hit. Shallow memoization requires immutable update discipline — mutate and it lies to you. This is the deepest one-sentence explanation of why React state must be updated immutably.
  • A throwing fn — nothing cached (assignments follow the call), next call retries; also means the slot still holds the previous good entry — subtle and worth saying.

Common mistakes

  • Building a Map cache (that's memoize; wrong contract, unbounded memory).
  • === element comparison with no NaN story.
  • Guarding on lastResult truthiness/undefined-ness instead of a sentinel.
  • Ignoring this.
  • "Fixing" reference misses with deep equality — you've now built a slow deep-equal per call (and that's its own question) and broken the O(1) promise; the ecosystem answer is stable references, not deeper comparison.

Follow-up questions

  • "Extend to cache size N." — an array of { args, result } with LRU order — and now justify N: for render-shaped workloads the second slot's hit rate is usually ~0, which is why React never added it.
  • "Custom equality?"memoizeLast(fn, isEqual) parameter — exactly reselect's createSelectorCreator; shallow-compare-props is React.memo's second argument. The API precedents are the answer.
  • "Why does useMemo sometimes recompute anyway?" — React reserves the right to drop the cache (concurrent rendering, offscreen); useMemo is a hint, not a guarantee — quoting the docs' stance here is a strong signal.
  • "Where's the line between this and API-call memoization?" — purity and lifetime: single-slot for pure render math; keyed cache + TTL + in-flight dedupe for I/O.
  • "Implement useMemo itself?" — per-hook-slot memoizeLast state stored on the fiber, keyed by call order — which is why the Rules of Hooks exist (call order is the cache key). One sentence, big unlock.

  • memoize()

    intermediate

    Cache function results by argument key — and discuss cache-key strategy and memory trade-offs.

  • _.once()

    beginner

    Guarantee a function runs exactly once and returns its cached result forever after.

  • _.partial()

    intermediate

    Pre-fill leading arguments of a function — partial application, and how it differs from currying.

  • pipe()

    intermediate

    Compose functions left-to-right into a pipeline — the one-liner with a lot of interview depth.