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?
answer
- a cache of one versus many
- per-instance selector factories
- WeakMap keyed on each argument
- primitive keys are held strongly
basics
~20 sReselect 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 sA 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 linesimport { 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
Recall that a selector can take extra arguments and that each input selector receives them all.
Explain Reselect 4's cache of one, why alternating arguments defeated it, and how selector factories with useMemo worked around it.
Explain weakMapMemoize's per-argument cache tree, the strong retention of primitive keys, and when to bound it with maxSize or switch to lruMemoize.
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