Some gotchas
useSnapshot(state)
without property access will always trigger re-render
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
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:
a. Do not use React.memo
.
b. Do not pass objects to components with React.memo
(pass primitive values instead).
c. 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:
a. Subscribe array proxy
b. Use the proxy as snapshot
c. Assign temp variable for updating
d. Remove proxy from the array
e. Update temp
f. 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
.