skip to content

In Reselect 5, how does createSelector cache results when one selector is called with different arguments from many components, and what changed from Reselect 4?

level: seniorimportance: should knowfreq 40%

answer

  1. a cache of one versus many
  2. per-instance selector factories
  3. WeakMap keyed on each argument
  4. primitive keys are held strongly

basics

~20 s

Reselect 5 memoises with weakMapMemoize by default, caching one result per distinct argument combination, so one selector serves many components. Reselect 4 cached only the last call, so alternating arguments recomputed every time and needed per-instance selectors.

solid answer

~50 s

A selector such as `selectItemsByCategory(state, category)` takes the extra argument through an input selector like `(_state, category) => category`. In **Reselect 4**, `createSelector` used `defaultMemoize` with a cache size of **1**: two components calling it with `'books'` and `'music'` evicted each other's result on every render, so teams wrote **per-instance selector factories** (`makeSelectItemsByCategory`) and created one per component with `useMemo`. **Reselect 5** switched the default for both `memoize` and `argsMemoize` to `weakMapMemoize`, which builds a cache tree keyed on each argument's identity, so one shared selector keeps a result per category. Object keys live in `WeakMap`s and are garbage-collected; primitive keys live in `Map`s and stay until `clearCache()`. For ever-changing primitives such as ids or timestamps, Reselect 5.3 added a `maxSize` option and a development `cacheSizeCheck` warning. `defaultMemoize` is now `lruMemoize`, still available with its own `maxSize`.

code

tsx · 16 lines
tsx
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { createSelector } from 'reselect'
import type { RootState } from './store'

const makeSelectItemsByCategory = () =>
  createSelector(
    [(state: RootState) => state.items, (_s: RootState, category: string) => category],
    (items, category) => items.filter((i) => i.category === category),
  )

export function CategoryPanel({ category }: { category: string }) {
  const selectItems = useMemo(makeSelectItemsByCategory, [])
  const items = useSelector((state: RootState) => selectItems(state, category))
  return <ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
}

go deeper

for a junior

Recall that a selector can take extra arguments and that each input selector receives them all.

for a middle

Explain Reselect 4's cache of one, why alternating arguments defeated it, and how selector factories with useMemo worked around it.

for a senior

Explain weakMapMemoize's per-argument cache tree, the strong retention of primitive keys, and when to bound it with maxSize or switch to lruMemoize.

for a principal

Weigh unbounded caching against memory in long-lived sessions, and set conventions for argument shapes and memoizer choice across shared selectors.

## Selectors with arguments Output selectors built with `createSelector` can take extra arguments after the state. Every input selector receives all the arguments, so an argument is fed to the result function by an input selector that returns it: ```ts const selectItemsByCategory = createSelector( [(state: RootState) => state.items, (_state: RootState, category: string) => category], (items, category) => items.filter((i) => i.category === category), ) useSelector((state: RootState) => selectItemsByCategory(state, category)) ``` The question is how many results the selector remembers when several components call it with different arguments. ## Reselect 4: a cache of one Up to version 4, `createSelector` used `defaultMemoize`, which remembers only the **most recent** call (cache size 1). 1. `CategoryPanel` for `'books'` calls the selector; the result is cached. 2. `CategoryPanel` for `'music'` calls it; the `'books'` entry is evicted. 3. On the next render `'books'` misses, recomputes and returns a **new array**, and so on. Every call recomputes and every panel receives a new reference each time, so the memoisation is worthless. The standard workaround was a **selector factory**, giving each component instance its own selector and therefore its own cache: ```ts const makeSelectItemsByCategory = () => createSelector([...], ...) const selectForThisPanel = useMemo(makeSelectItemsByCategory, []) ``` Another option was a bigger LRU cache, passing `maxSize` to the memoizer. ## Reselect 5: weakMapMemoize by default Reselect 5 changed the default for both stages of memoisation — `memoize` for the result function and `argsMemoize` for the arguments — to **`weakMapMemoize`**. - It builds a **tree of caches**, one level per argument, keyed on each argument's identity. - **Object and function** arguments are keys in `WeakMap`s, so their entries are released when the object is garbage-collected — for instance an old root state after later dispatches. - **Primitive** arguments — strings, numbers, booleans — are keys in ordinary `Map`s, held **strongly** until the selector's `clearCache()` is called. The effect is a cache that is effectively unbounded: one shared selector keeps a separate result for `'books'` and for `'music'`, and factories are no longer needed for this case. `defaultMemoize` was renamed **`lruMemoize`** and is still exported for when you want its behaviour. | | Reselect 4 default | Reselect 5 default | |---|---|---| | Memoizer | `defaultMemoize` (now `lruMemoize`) | `weakMapMemoize` | | Results remembered | the last call only | one per distinct argument combination | | Alternating arguments | recompute on every call | cached per argument | | Custom equality | `equalityCheck` option | argument comparison is by reference only | | Growth control | `maxSize` | `maxSize` since 5.3, or `clearCache()` | ## The catch: primitive arguments pile up A selector called with ever-changing primitives — an id for every row ever shown, a pagination offset, a timestamp — adds a `Map` entry per value, and those entries are never garbage-collected. Reselect **5.3** addressed this in two ways: - a `maxSize` option on `weakMapMemoize`, which bounds retention for primitive keys (generationally, at roughly twice `maxSize`); - a development-only **`cacheSizeCheck`** that warns when one argument position has seen more than 1,000 distinct primitive values. ## Choosing a memoizer ```ts createSelector([selectItems, selectId], (items, id) => items.find((i) => i.id === id), { memoize: lruMemoize, memoizeOptions: { maxSize: 50 }, argsMemoize: lruMemoize, }) ``` - Keep the default for selectors called with a **bounded** set of arguments, such as categories or tabs. - Pass `maxSize` to `weakMapMemoize`, or switch to `lruMemoize` with a `maxSize`, when arguments are **unbounded**. - Use `lruMemoize` when you need a custom `equalityCheck`, which `weakMapMemoize` does not support. ## Argument hygiene - Pass **primitives or stable references** as arguments. `selectX(state, { category })` creates a new object on every render, which is a new cache key every time, so the selector recomputes and returns a new reference. - Do not build the selector inside a component body without `useMemo`; each render would start a new cache. - Keep argument **order** consistent. Arguments are matched by position, so `selectX(state, a, b)` and `selectX(state, b, a)` are different cache paths as well as different inputs. A senior answer contrasts the two defaults, explains why factories were needed and when they still help, and names the primitive-argument growth issue with its 5.3 remedies.

  • Are per-instance selector factories still needed with Reselect 5?
    Rarely for correctness, because the default `weakMapMemoize` keeps a result per distinct argument. A factory still helps when you want a component's cache released with the component, or when an unbounded stream of primitive arguments would otherwise pile up in one shared selector's cache.
  • Why is selectX(state, { category }) a problem with Reselect's default memoizer?
    A new object literal on every call is a new argument identity, so `weakMapMemoize` misses the cache every time, reruns the inputs and may recompute, returning a new reference. Pass the primitive `category` directly, or a stable object reference.

saying these in an interview costs you the question

  • Reselect 5 still caches only the last call by default
  • weakMapMemoize releases primitive-keyed results through garbage collection
  • weakMapMemoize accepts a custom equalityCheck for arguments
  • Passing an object literal argument is as cache-friendly as a primitive
  • defaultMemoize is still the name of Reselect's default memoizer