Some gotchas
useSnapshot(state)
without property access will always trigger re-render
Ref: https://github.com/pmndrs/valtio/issues/209#issuecomment-896859395
Suppose we have this state (or store).
const state = proxy({
obj: {
count: 0,
text: 'hello',
},
})
If using the snapshot with accessing count,
const snap = useSnapshot(state)
snap.obj.count
it will re-render only if count
changes.
If the property access is obj,
const snap = useSnapshot(state)
snap.obj
then, it will re-render if obj
changes. This includes count
changes and text
changes.
Now, we can subscribe to the portion of the state.
const snapObj = useSnapshot(state.obj)
snapObj
This is technically same as the previous one. It doesn't touch the property of snapObj
, so it will re-render if obj
changes.
In summary, if a snapshot object (nested or not) is not accessed with any properties, it assumes the entire object is accessed, so any change inside the object will trigger re-render.
Using React.memo
with object props may result in unexpected behavior (v1 only)
⚠️ This behavior is fixed in v2.
The snap
variable returned by useSnapshot(state)
is tracked for render optimization.
If you pass the snap
or some objects in snap
to a component with React.memo
,
it may not work as expected because React.memo
can skip touching object properties.
Side note: react-tracked has a special memo
exported as a workaround.
We have some options:
- Do not use
React.memo
. Do not pass objects to components with
React.memo
(pass primitive values instead).Pass in the proxy of that element, and then
useSnapshot
on that proxy.
Example of (b)
const ChildComponent = React.memo(
({
title, // string or any primitive values are fine.
description, // string or any primitive values are fine.
// obj, // objects should be avoided.
}) => (
<div>
{title} - {description}
</div>
),
)
const ParentComponent = () => {
const snap = useSnapshot(state)
return (
<div>
<ChildComponent
title={snap.obj.title}
description={snap.obj.description}
/>
</div>
)
}
Example of (c)
const state = proxy({
objects: [
{ id: 1, label: 'foo' },
{ id: 2, label: 'bar' },
],
})
const ObjectList = React.memo(() => {
const stateSnap = useSnapshot(state)
return stateSnap.objects.map((object, index) => (
<Object key={object.id} objectProxy={state.objects[index]} />
))
})
const Object = React.memo(({ objectProxy }) => {
const objectSnap = useSnapshot(objectProxy)
return objectSnap.bar
})
When to use state
and when to use snap
in functional components
- snap should be used in render function, every other cases state.
- callback functions are not in the render body and therefore state must be used.
const Component = () => {
// this is in render body
const handleClick = () => {
// this is NOT in render body
}
return <button onClick={handleClick}>button</button>
}
- deps in useEffect should be used extracting primitive values from snap. For example:
const { num, string, bool } = snap.watchObj
. - changing a state value based on other state values (without involving values like props in a component), should preferably done outside react.
subscribe(state.subscribeData, async () => {
state.results = await load(state.someData)
})
Issue with array
proxy
The following use case can occur unexpected results on arr
subscription:
const byId = {}
arr.forEach((item) => {
byId[item.id] = item
})
arr.splice(0, arr.length)
arr.push(newValue())
someUpdateFunc(byId)
Object.keys(byId).forEach((key) => arr.push(byId[key]))
Issues may arise when handling the array proxy reference in the subsequent steps:
- Subscribe array proxy
- Use the proxy as snapshot
- Assign temp variable for updating
- Remove proxy from the array
- Update temp
- Push temp in the original array
Example issue case:
const a = proxy([
{
nested: {
nested: {
test: 'apple',
},
},
},
])
const sa = snapshot(a) // b.
// a.
subscribe(a, () => {
const updated = snapshot(a)
console.log('this is updated proxy. test is Banana', a)
console.log('however, for the snapshot of a, test is still apple', updated)
})
function handle() {
const temp = a[0] // c.
a.splice(0, 1) // d.
temp.nested.nested.test = 'Banana' // e.
a.push(temp) // f.
console.log(Object.is(temp, a[0])) // this will be true
}
To work around this, swap d and e:
// ...
function handle() {
const temp = a[0]
temp.nested.nested.test = 'Banana' // Update first remove from array
a.splice(0, 1)
a.push(temp)
}
// ...
If the workaround is not applied and you are using react with devtools(), the redux devtools will notify a value update, but the snapshot will remain the same within the devtools' subscription.
As a result, the devtools will not display any state change.
Additionally, this issue involved not only updating devtools, but also triggering re-render
.