Memoise a derived value with typed dependencies

The problem

A table groups and sorts thousands of rows on every render, and the work repeats each time an unrelated checkbox in the same component changes. The derivation is typed, so the memo boundary has to name its inputs without breaking the types, and the reused result must be indexable by region without a lookup inside a loop.

Short answer

Wrap the derivation in useMemo whose dependency array lists the values that actually change, move any intermediate filtering inside the callback, and pass a module-scope key function to the generic index hook so its identity stays stable.

A memo boundary is only as good as the identity of the values listed inside it. This pair of hooks keeps a heavy derivation in one place, types the reused result, and builds a keyed lookup from it without scanning the list on every render.

Language: TypeScript
// src/use-keyed-index.ts
import { useMemo } from "react";

export function useKeyedIndex<TItem, TKey extends string | number>(
  items: readonly TItem[],
  keyOf: (item: TItem) => TKey,
): ReadonlyMap<TKey, TItem> {
  return useMemo(() => {
    const entries = items.map((item): [TKey, TItem] => [keyOf(item), item]);
    return new Map(entries);
  }, [items, keyOf]);
}

Language: TSX
// src/sales-leaderboard.tsx
import { useMemo, useState } from "react";
import { useKeyedIndex } from "./use-keyed-index";

export type SaleRow = {
  readonly region: string;
  readonly amountCents: number;
  readonly isDraft: boolean;
};

export type RegionTotal = {
  readonly region: string;
  readonly totalCents: number;
};

function sumByRegion(rows: readonly SaleRow[]): readonly RegionTotal[] {
  const totals = new Map<string, number>();

  for (const row of rows) {
    totals.set(row.region, (totals.get(row.region) ?? 0) + row.amountCents);
  }

  return [...totals]
    .map(([region, totalCents]): RegionTotal => ({ region, totalCents }))
    .sort((left, right) => right.totalCents - left.totalCents);
}

const regionKey = (total: RegionTotal): string => total.region;

type SalesLeaderboardProps = {
  readonly rows: readonly SaleRow[];
};

export function SalesLeaderboard({ rows }: SalesLeaderboardProps) {
  const [hideDrafts, setHideDrafts] = useState(false);
  const [selectedRegion, setSelectedRegion] = useState("");

  const totals = useMemo(
    () => sumByRegion(hideDrafts ? rows.filter((row) => !row.isDraft) : rows),
    [rows, hideDrafts],
  );

  const indexByRegion = useKeyedIndex(totals, regionKey);
  const selected = selectedRegion === "" ? undefined : indexByRegion.get(selectedRegion);

  return (
    <div className="leaderboard">
      <label>
        <input
          type="checkbox"
          checked={hideDrafts}
          onChange={() => setHideDrafts((current) => !current)}
        />
        Hide draft sales
      </label>

      <ol>
        {totals.map((total) => (
          <li key={total.region}>
            <button type="button" onClick={() => setSelectedRegion(total.region)}>
              {total.region}
            </button>
          </li>
        ))}
      </ol>

      <p aria-live="polite">
        {selected === undefined
          ? "Choose a region to see its total."
          : `${selected.region}: ${(selected.totalCents / 100).toFixed(2)} booked.`}
      </p>
    </div>
  );
}

Explanation

useMemo is generic over the value it produces, and the generic is resolved from the callback’s return type, so totals is readonly RegionTotal[] with no annotation at the call site and every later use of it keeps that type. The dependency array is compared element by element with the same equality test used for the cache, which is why it must contain the values the callback reads: here those are the rows prop and the hideDrafts flag. The filter is inside the callback rather than in a const visibleRows above it for exactly this reason. A filtered array built during render is a new object on every render, so listing it as a dependency would compare unequal every time and the memo would recompute the sum on renders where nothing relevant changed — the call would still be correct, just pointless.

useKeyedIndex shows the same rule at a second remove, and the typing is what makes the pattern reusable. The item type and the key type are both parameters, with TKey extends string | number so the resulting Map keys are legitimately keyed rather than widened to unknown, and the return type ReadonlyMap<TKey, TItem> tells callers they may look up but may not mutate the cached index. Inside, items.map((item): [TKey, TItem] => ...) carries an explicit tuple return annotation; without it, the inferred type of the array literal is an array of the union TKey | TItem and the Map constructor rejects it. That annotation is a declaration rather than an assertion, so it still fails to compile if the value is wrong. The trade-off is the second dependency: keyOf is in the array, so passing an inline arrow function would rebuild the map on every render, and the reason regionKey sits at module scope is that its identity is then fixed for the lifetime of the page.

The boundary condition that decides whether any of this is worth writing is React’s own guarantee: memo caches may be discarded, for example when a subtree is hidden and shown again, so the code must stay correct when the callback runs again from scratch. That holds here because sumByRegion is pure and derived only from props and state. It would not hold if the derivation read a mutable value from outside, or if the result were treated as storage — in those cases the derivation belongs in state, set in an event handler, or better still computed during render with no memo at all.

Usage notes

  • Measure before memoising: the win exists only when the derivation is expensive enough to outweigh the comparison of the dependency array.
  • Keep the dependency array exactly the values the callback reads, because a shorter array is a stale result and a longer one is a cache that never hits.

Common mistakes

  • Passing an inline arrow function as the key argument of the index hook, which changes identity each render and rebuilds the map the memo was meant to protect.

Caveats

  • React treats this cache as disposable and may throw it away between renders, so memoisation is a performance decision and never a place to keep state.
  • A value built inside the render, such as a filtered copy of an array prop, is a new object every time and invalidates the memo it is listed in.

Prerequisite

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References