proxy-memoize
Intuitive magical memoization library with Proxy and WeakMap
Project status
The API is complete. Unless we find some major issues, it will be fixed. Before reaching v1, we would like to collect more bug reports and best practices. There are no obvious/known issues at the moment, but there are some limitations and workarounds.
Introduction
In frontend framework like React, object immutability is important. JavaScript itself doesn't support forcing immutability. Several libraries help encouraging immutable coding style, like immer. While immer helps updating an object, this library helps creating a derived value from an object, a.k.a. selector.
This library utilizes Proxy and WeakMap, and provides memoization. The memoized function will re-evaluate the original function only if the used part of argument (object) is changed. It's intuitive in a sense and magical in another sense.
How it works
When it (re-)evaluates a function, it will wrap an input object with proxies (recursively, on demand) and invoke the function. When it's finished it will check what is "affected". The "affected" is a list of paths of the input object that are accessed during the function invocation.
Next time when it receives a new input object, it will check if values in "affected" paths are changed. If so, it will re-evaluate the function. Otherwise, it will return a cached result.
The cache size is 1
by default, but configurable.
We have 2-tier cache mechanism. What is described so far is the second tier cache.
The first tier cache is with WeakMap. It's a WeakMap of the input object and the result of function invocation. There's no notion of cache size.
In summary, there are two types of cache:
- tier-1: WeakMap based cache (size=infinity)
- tier-2: Proxy based cache (size=1, configurable)
Install
npm install proxy-memoize
How it behaves
import memoize from 'proxy-memoize';
const fn = memoize(x => ({ sum: x.a + x.b, diff: x.a - x.b }));
fn({ a: 2, b: 1, c: 1 }); // ---> { sum: 3, diff: 1 }
fn({ a: 3, b: 1, c: 1 }); // ---> { sum: 4, diff: 2 }
fn({ a: 3, b: 1, c: 9 }); // ---> { sum: 4, diff: 2 } (returning a cached value)
fn({ a: 4, b: 1, c: 9 }); // ---> { sum: 5, diff: 3 }
fn({ a: 1, b: 2 }) === fn({ a: 1, b: 2 }); // ---> true
Usage with React Context
Instead of bare useMemo.
const Component = (props) => {
const [state, dispatch] = useContext(MyContext);
const render = useCallback(memoize(([props, state]) => (
<div>
{/* render with props and state */}
</div>
)), [dispatch]);
return render([props, state]);
};
const App = ({ children }) => (
<MyContext.Provider value={useReducer(reducer, initialState)}>
{children}
</MyContext.Provider>
);
Usage with React Redux
Instead of reselect.
import { useSelector } from 'react-redux';
const getScore = memoize(state => ({
score: heavyComputation(state.a + state.b),
createdAt: Date.now(),
}));
const Component = ({ id }) => {
const { score, title } = useSelector(useCallback(memoize(state => ({
score: getScore(state),
title: state.titles[id],
})), [id]));
return <div>{score.score} {score.createdAt} {title}</div>;
};
Using size
option
The example above might seem tricky to create memoized selector in component.
Alternatively, we can use size
option.
import { useSelector } from 'react-redux';
const getScore = memoize(state => ({
score: heavyComputation(state.a + state.b),
createdAt: Date.now(),
}));
const selector = memoize(([state, id]) => ({
score: getScore(state),
title: state.titles[id],
}), {
size: 500,
});
const Component = ({ id }) => {
const { score, title } = useSelector(state => selector([state, id]));
return <div>{score.score} {score.createdAt} {title}</div>;
};
The drawback of this approach is we need a good estimate of size
in advance.
Usage with Zustand
For derived values.
import create from 'zustand';
const useStore = create(set => ({
valueA,
valueB,
// ...
}));
const getDerivedValueA = memoize(state => heavyComputation(state.valueA))
const getDerivedValueB = memoize(state => heavyComputation(state.valueB))
const getTotal = state => getDerivedValueA(state) + getDerivedValueB(state)
const Component = () => {
const total = useStore(getTotal)
return <div>{total}</div>;
};
API
memoize
Create a memoized function
Parameters
fn
function (obj: Obj): Resultoptions
{size: number?}?
Examples
import memoize from 'proxy-memoize';
const fn = memoize(obj => ({ sum: obj.a + obj.b, diff: obj.a - obj.b }));
Returns function (obj: Obj): Result
getUntrackedObject
This is to unwrap a proxy object and return an original object. It returns null if not relevant.
[Notes] This function is for debugging purpose. It's not supposed to be used in production and it's subject to change.
Examples
import memoize, { getUntrackedObject } from 'proxy-memoize';
const fn = memoize(obj => {
console.log(getUntrackedObject(obj));
return { sum: obj.a + obj.b, diff: obj.a - obj.b };
});
Limitations and workarounds
Inside the function, objects are wrapped by proxies and touching a property will record it.
const fn = memoize(obj => {
console.log(obj.c); // this will mark ".c" as used
return { sum: obj.a + obj.b, diff: obj.a - obj.b };
});
A workaround is to unwrap a proxy.
const fn = memoize(obj => {
console.log(getUntrackedObject(obj).c);
return { sum: obj.a + obj.b, diff: obj.a - obj.b };
});
Memoized function will unwrap proxies in the return value only if it consists of plain objects/arrays.
const fn = memoize(obj => {
return { x: obj.a, y: { z: [obj.b, obj.c] } }; // plain objects
});
In this case above, the return value is clean, however, see the following.
const fn = memoize(obj => {
return { x: new Set([obj.a]), y: new Map([['z', obj.b]]) }; // not plain
});
We can't unwrap Set/Map or other non-plain objects.
The problem is when obj.a
is an object (which will be wrapped by a proxy)
and touching its property will record the usage, which leads
unexpected behavior.
If obj.a
is a primitive value, there's no problem.
There's no workaround. Please be advised to use only plain objects/arrays. Nested objects/arrays are OK.
Input object must not be mutated
const fn = memoize(obj => {
return { sum: obj.a + obj.b, diff: obj.a - obj.b };
});
const state = { a: 1, b: 2 };
const result1 = fn(state);
state.a += 1; // Don't do this, the state object must be immutable
const result2 = fn(state); // Ends up unexpected result
The input obj
or the state
must be immutable.
The whole concept is built around the immutability.
It's faily common in Redux and React,
but be careful if you are not familiar with the concept.
There's no workaround.
Input can just be one object
const fn = memoize(obj => {
return { sum: obj.a + obj.b, diff: obj.a - obj.b };
});
The input obj
is the only argument that a function can receive.
const fn = memoize((arg1, arg2) => {
// arg2 can't be used
// ...
});
A workaround is to create a wrapper.
const memoizeWithArgs = (fnWithArgs, options) => {
const fn = memoize(args => fWithArgs(...args), options);
return (...args) => fn (args);
};
Note: this will essentially bypass the tier-1 cache with WeakMap.
Comparison
Reselect
Here's a simple example in reselect.
import { createSelector } from 'reselect';
const mySelector = createSelector(
state => state.values.value1,
state => state.values.value2,
(value1, value2) => value1 + value2,
);
This can be written as follows.
import memoize from 'proxy-memoize';
const mySelector = memoize(
state => state.values.value1 + state.values.value2,
);
Another example from reselect.
const subtotalSelector = createSelector(
state => state.shop.items,
items => items.reduce((acc, item) => acc + item.value, 0),
);
const taxSelector = createSelector(
subtotalSelector,
state => state.shop.taxPercent,
(subtotal, taxPercent) => subtotal * (taxPercent / 100),
);
export const totalSelector = createSelector(
subtotalSelector,
taxSelector,
(subtotal, tax) => ({ total: subtotal + tax }),
);
This can be converted to something like this.
export const totalSelector = memoize(state => {
const subtotal = state.shop.item.reduce((acc, item) => acc + item.value, 0);
const tax = subtotal * (state.shop.taxPercent / 100);
return { total: subtotal + tax };
);
Finally, see this example.
const state = {
todos: [
{ text: 'foo', completed: false }
]
};
const todoTextsSelector = memoize(state => state.todos.map(todo => todo.text));
This can't be written in reselect because if the completed
value is toggled, the todoTextsSelector
will return a referentially new array, even though the contents is shallow equal.