1. Some gotchas

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.