A State Management Tour: Proxy State With Valtio
Most React applications start small, with clearly separated states and components. As an application gets more complex, it becomes more and more difficult to organize and maintain its state: some components have to share some state with others, some have to get a subset of the state, and some have to re-render when a specific part of the state changes... How do I deal with this complexity?
And so started my journey into state management tools.
During my search for the truth, I stumbled upon Valtio, a proxy state management for React. This is the library I'll explore in this post. I will explain how it works, the concepts behind it, and why it can be helpful. Several other posts will follow, exploring other state management solutions.
A Quick Refresher On Proxies
Valtio says of itself that it "makes proxy-state simple". But, what is "proxy state"? I have a vague knowledge of the Proxy Pattern, is it related?
Indeed, it is based on this concept. To make it short, let's say I have a book object. A proxy "wraps" the book object. To access the object, I have to pass through the proxy, and so, the proxy can do extra operations before returning the book, like logging.
Proxies were introduced in ES6.
Let's try to do logging with JavaScript's Proxy
. If I want to log all access to an object, I can use a Proxy to intercept get
calls, and do a console.log
with the parameters. To do so, I use the Proxy
class, with 2 parameters: target
(the object to wrap) and handler
(the behaviors I want to add).
// the handler intercepts the read action (get) and calls console.log before returning the value.
const loggingHandler = {
get(target, property) {
console.log(`Reading ${property}`);
return target[property];
},
};
const target = {
count: 1,
title: 'A random object',
};
// bind the proxy to a target
const proxyTarget = new Proxy(target, loggingHandler);
Now, when I read a property of proxyTarget
, it runs the loggingHandler
at the same time:
> const count = proxyTarget.count;
Reading count
> console.log(proxyTarget.title)
Reading title
'A random object'
Proxies seem cool, but, why would I use them in a React app?
Sharing State With Props
One of the most common problems when dealing with multiple components is to manage shared state, and to update the concerned components whenever a state property changes. How do I handle that?
The first solution I think about is to use props. I can pass the state as props to the components I want to share the state with.
const Store = () => {
// Declare state
const [beers] = useState([
{ name: "Hazy rider", brewery: "Hoppy Road" },
{ name: "BP Porter", brewery: "Bon Poison" }
]);
return (
<>
{/* Pass the state as props */}
<Basket beers={beers} />
</>
);
};
// Get the state from the props
const Basket = ({ beers }) => {
return (
<>
{beers.map((beer) => (
<div key={beer.name}> {`${beer.name} - ${beer.brewery}`}</div>
))}
</>
);
};
But it can quickly become a burden to pass props down several layers (a.k.a. "Prop Drilling"). And what if the props I want to pass are not for a child component but for a parent one, or a sibling? It's not the ideal solution.
Sharing State With Context
React simplifies this with Context
. It allows giving access to the state to all the components inside a Context.Provider
.
// Create the context
const BeersContext = createContext();
const Store = () => {
const state = {
beers: [
{
name: "Hazy rider",
brewery: "Hoppy Road"
},
{
name: "BP Porter",
brewery: "Bon Poison"
}
]
};
// Wrap it in contextProvider
return (
<BeersContext.Provider value={state}>
<Basket />
</BeersContext.Provider>
);
};
const Basket = () => {
// Get it from context
const { beers } = useContext(BeersContext);
return (
<>
{beers.map((beer) => (
<div key={beer.name}> {`${beer.name} - ${beer.brewery}`}</div>
))}
</>
);
};
It seems to be better: no more prop drilling. But it has some limitations: components that want access to the context need to be wrapped in a Provider. Furthermore, I can't subscribe to a subpart of a context. As a consequence, all the components that only depend on a subpart of the context re-renders whenever any state change occurs, even if that change doesn't concern them. This can quickly cause performance issues.
Using Proxies To Share State
How can I solve this problem?
When reading a state object, I want to subscribe to updates of parts of this state. Ideally, I'd like to read state properties without any special syntax - just like when reading properties of a regular object. When the property I subscribed to changes, I'd like the component to re-render.
Proxies make that possible: by wrapping state in a proxy, and using a pub/sub system instead of React context, I can get fine-grained state updates with an intuitive syntax. Components basically subscribe to the state to get notified of changes.
The proxy redefines the mutations (set
, deleteProperty
) to notify listeners whenever a change occurs.
And, that's it...
Building A Proxy-State With Valtio
Valtio provides a set of methods to ease the creation of a proxified store.
I create a proxy state using the proxy
function:
import { proxy } from 'valtio';
export const state = proxy({
count: 1,
title: 'A dumb state',
});
I can also define mutations that update the proxy state. For instance:
export const setTitle = newTitle => {
state.title = newTitle;
};
Note that when I do state.title = newTitle
, I manipulate a proxy of the state, not the state itself. Thus by setting a value, behind the scene it triggers some other behaviors defined by Valtio.
Reading And Updating A Proxy-State
Now I can use this state in React components. But if I read directly the state object, I miss the rendering optimization done by Valtio.
So I am going to use the useSnapshot
hook. It returns another proxy, that subscribes to the source proxy, and therefore benefits from the optimization.
The rule of thumb is: read from snapshots in the render function, otherwise use the source proxy.
import { useSnapshot } from 'valtio';
import { state, setTitle } from './state';
const DumbComponent = () => {
const snapshot = useSnapshot(state);
return (
<input
type="text"
// I read from snapshot
value={snapshot.title}
// but I update via the source
onChange={evt => setTitle(evt.target.value)}
/>
);
};
With this technique, I can manage almost all the state management use cases. But Valtio provides some utilities that help a little more.
Computing Derived State
Sometimes I need to derive state from other pieces of state. For example, if I have a basket of products (let's say... beers), I may want to handle the number of products in the basket.
I can do so by using the derive
utility. derive
take as a parameter an object of functions defining the derivation we want. Those functions are passed a get
function to access the state.
import { derive, proxy } from 'valtio';
export const state = proxy({
selectedBeers: [
{ name: 'Guinness', quantity: 2 },
{ name: 'Budweiser', quantity: 3 },
],
});
export const countState = derive({
total: get =>
// I access the state with the get function provided by Valtio
get(state).selectedBeers.reduce((acc, beer) => {
acc += beer.quantity;
return acc;
}, 0),
});
In a React component, I can use the derived value with useSnapshot
, just like for the proxy state:
import { countState } from './basket';
const TotalComponent = () => {
const { total } = useSnapshot(countState);
return <div>{total}</div>;
};
That's it for the principle of derived state. To dive deeper, let's take a (little) more complex case.
Fine-Tuned Rendering With Valtio
I've written a state representing a basket for a beer store:
import { proxy, useSnapshot } from 'valtio';
import { derive } from 'valtio/utils';
import { Beer } from '../types';
interface BasketState {
isBasketOpen: boolean;
selectedBeers: { beer: Beer, quantity: number }[];
}
export const basketState = proxy<BasketState>({
isBasketOpen: false,
selectedBeers: [],
});
I also wrote a component called <BeerCard>
, which displays info about a specific beer, together with the number of bottles of this beer that are in the basket.
I could write this component like this:
[...]
const BeerCard = (props: { beer: Beer }) => {
const { beer } = props;
const { selectedBeers } = useSnapshot(basketState);
const quantity = useMemo(
() => selectedBeers.find((b) => b.beer.id === beer.id)?.quantity,
[selectedBeers, beer]
);
return (
<Card
key={beer.id}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
[...]
<Typography color="text.primary" gutterBottom>
{beer.name} - {quantity}
</Typography>
[...]
</Card>
);
};
But this approach is not optimized, as every BeerCard re-renders when updating the basket.
I want React to re-render only the card containing the beer updated by the user. With Valtio, I can use the derive
method.
First, let's add a method in my store to get a derived state for a specific beer:
const derivedState: Record<string, { quantity: number }> = {};
export const getBeerQuantityState = (beerId: number) => {
// If I haven't yet derived a state for the beer
if (!derivedState[beerId]) {
// I derive it
const derivedBeerState = derive({
quantity: get =>
get(basketState).selectedBeers.find(
(b: { beer: Beer, quantity: number }) =>
b.beer.id === beerId,
)?.quantity || 0,
});
// Store it
derivedState[beerId] = derivedBeerState;
}
// And return the derived state of the beer
return derivedState[beerId];
};
I end up with a derivedState
containing something like :
Object
id1: Proxy {quantity: 2} // a derived state for beer with id 'id1'
id2: Proxy {quantity: 4} // a derived state for beer with id 'id2'
id3: Proxy {quantity: 1} // a derived state for beer with id 'id3'
id4: Proxy {quantity: 0} // ...
And then in my BeerCard
Component :
const { quantity } =
// I get the derived state for the beer
useSnapshot < { quantity: number } > getBeerQuantityState(beer.id);
And... that's all. I have a proxy state for each BeerCard
, and Valtio can do its magic tricks to notify only the right component.
[...]
const BeerCard = (props: { beer: Beer }) => {
const { beer } = props;
const { selectedBeers } = useSnapshot(basketState);
- const quantity = useMemo(
- () => selectedBeers.find((b) => b.beer.id === beer.id)?.quantity,
- [selectedBeers, beer]
- );
+ const { quantity } =
+ useSnapshot < { quantity: number } > getBeerQuantityState(beer.id);
return (
<Card
key={beer.id}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
}}
>
[...]
<Typography color="text.primary" gutterBottom>
{beer.name} - {quantity}
</Typography>
[...]
</Card>
);
};
Behind The Magic Of Valtio
Valtio looks a bit magic, so I wanted to understand how it works. Let's take a look inside to demystify it.
First, as stated before, it uses a Proxy
, and it adds behaviors on the set
method of the state.
Let's have a quick look inside the handler defined in Valtio code (I've truncated and modified a little bit the code to ease the understanding):
set(target: T, prop: string | symbol, value: any, receiver: any) {
const prevValue = Reflect.get(target, prop, receiver)
if (this.is(prevValue, value)) {
return true
}
[...]
nextValue = value
[...]
Reflect.set(target, prop, nextValue, receiver)
notifyUpdate(['set', [prop], value, prevValue])
return true
},
}
Complete source code - if you want
set(target: T, prop: string | symbol, value: any, receiver: any) {
const prevValue = Reflect.get(target, prop, receiver)
if (this.is(prevValue, value)) {
return true
}
const childListeners = prevValue?.[LISTENERS]
if (childListeners) {
childListeners.delete(popPropListener(prop))
}
if (isObject(value)) {
value = getUntracked(value) || value
}
let nextValue: any
if (Object.getOwnPropertyDescriptor(target, prop)?.set) {
nextValue = value
} else if (value instanceof Promise) {
nextValue = value
.then((v) => {
nextValue[PROMISE_RESULT] = v
notifyUpdate(['resolve', [prop], v])
return v
})
.catch((e) => {
nextValue[PROMISE_ERROR] = e
notifyUpdate(['reject', [prop], e])
})
} else if (value?.[LISTENERS]) {
nextValue = value
nextValue[LISTENERS].add(getPropListener(prop))
} else if (this.canProxy(value)) {
nextValue = proxy(value)
nextValue[LISTENERS].add(getPropListener(prop))
} else {
nextValue = value
}
Reflect.set(target, prop, nextValue, receiver)
notifyUpdate(['set', [prop], value, prevValue])
return true
}
This method starts by getting the previous value Reflect.get(target, prop, receiver)
. Reflect
is a JS object that allows intercepting some methods. Here, it intercepts the get
method. Then it compares it to the new value. If there is no change, it does nothing. Otherwise, it sets the value Reflect.set(target, prop, nextValue, receiver)
, and notifies all listeners notifyUpdate(['set', [prop], value, prevValue])
.
The useSnapshot
hook adds listeners to the proxy:
export function useSnapshot<T extends object>(
proxyObject: T,
options?: Options
): Snapshot<T> {
[...]
useCallback(
(callback) => {
lastCallback.current = callback
const unsub = subscribe(proxyObject, callback, notifyInSync)
return () => {
unsub()
lastCallback.current = undefined
}
},
[proxyObject, notifyInSync]
)
[...]
Complete source code - if you want
export function useSnapshot<T extends object>(
proxyObject: T,
options?: Options
): Snapshot<T> {
const affected = new WeakMap()
const lastAffected = useRef<typeof affected>()
const lastCallback = useRef<() => void>()
const notifyInSync = options?.sync
const currSnapshot = useSyncExternalStore(
useCallback(
(callback) => {
lastCallback.current = callback
const unsub = subscribe(proxyObject, callback, notifyInSync)
return () => {
unsub()
lastCallback.current = undefined
}
},
[proxyObject, notifyInSync]
),
useMemo(() => {
let prevSnapshot: Snapshot<T> | undefined
return () => {
const nextSnapshot = snapshot(proxyObject)
try {
if (
prevSnapshot &&
lastAffected.current &&
!isChanged(
prevSnapshot,
nextSnapshot,
lastAffected.current,
new WeakMap()
)
) {
// not changed
return prevSnapshot
}
} catch (e) {
// ignore if a promise or something is thrown
}
return (prevSnapshot = nextSnapshot)
}
}, [proxyObject]),
useCallback(() => snapshot(proxyObject), [proxyObject])
)
const currVersion = getVersion(proxyObject)
useEffect(() => {
lastAffected.current = affected
// check if state has changed between render and commit
if (currVersion !== getVersion(proxyObject)) {
if (lastCallback.current) {
lastCallback.current()
} else if (
typeof process === 'object' &&
process.env.NODE_ENV !== 'production'
) {
console.warn('[Bug] last callback is undefined')
}
}
})
if (__DEV__) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useAffectedDebugValue(currSnapshot, affected)
}
const proxyCache = useMemo(() => new WeakMap(), []) // per-hook proxyCache
return createProxyToCompare(currSnapshot, affected, proxyCache)
}
The important part is the subscribe(proxyObject, callback, notifyInSync)
, which subscribes a listener to the proxy.
So I have a proxy
that creates a Proxy wrapping state, and that notifies a list of listeners when there are updates. I also have a useSnapshot
, that creates another Proxy subscribing to the original proxyObject
via a callback.
That explains the fundamental mechanism behind Valtio. In addition to that, Valtio also does some optimization, but I will not dive into this.
Will I use Valtio ?
I'll definitely use Valtio on my next projects! Its simplicity and conciseness are really good points in my opinion. It makes me wonder why I've used more verbose and complex patterns such as Redux in the past (I know, I know, some use cases can justify such a choice (do they? :P)).
So if you want to try a new state management library, Valtio would be my advice. Just keep in mind that it lets you organize your state as you want. You are responsible for that, so choose wisely how you split your state.
You can find the code of my experiment with Valtio in the guilbill/beer-store-valtio repository on GitHub.