Async selectors mark downstream selectors modified when they're not, causing components to re-render when they don't need to
#1937 opened on Aug 8, 2022
Description
At Zapier, we were debugging excessive component updates when we noticed that when an async selector gets recomputed because a dependency has changed, the selector, and all that depend on it always get marked as modified, even when the initial dependency change doesn't produce any downstream effects. This also causes any component that subscribes to a selector that depends on this async selector to be updated when it doesn't need to.
I've created a reproducing CodeSandbox at https://codesandbox.io/s/recoil-downstream-wksim9?file=/pages/index.tsx
This features the follow data-flow graph:
atom<number>(counterState)
⌙> selector<Promise<boolean> | boolean>(hasIncrementedCounterBooleanState)
⌙> selector<'yes' | 'no'>(hasIncrementedCounterStringState)
In the view:
- You can toggle
hasIncrementedCounterBooleanStateto be async using the "Disable Async" button. - You can increment
counterSateusing the "Increment Counter" button. - We only subscribe to the last
hasIncrementedCounterStringStateselector. - We log the number of times the component was rendered (its FC executed).
The console tab will show:
- 💻 When a selector recomputes.
- 🎨 When the main or Suspense
LoadingFallbackcomponent renders (executes). - ⏰ When the Recoil snapshot updates and what values it has marked as
isModified: true.
With hasIncrementedCounterBooleanState async (by default):
- When you first increment, as expected both selectors compute and all 3 values are marked as modified.
- When you increment again, as expected the
hasIncrementedCounterBooleanStateselector recomputes because it depends on the changedcounterState, buthasIncrementedCounterStringStatedoes not recompute since its dependencyhasIncrementedCounterBooleanStateresolves to the same value (true). - However, what is not expected is that the second time, snapshot logger does show
hasIncrementedCounterBooleanStateandhasIncrementedCounterStringStateas modified, even though their values didn't change, and the latter didn't even recompute. - What is also not expected, but likely related to the above is that the component updates, even though it only subscribes to
hasIncrementedCounterStringStatewhich didn't get recomputed, let alone had its value change.
With hasIncrementedCounterBooleanState sync, for the second and following increments:
- As expected show
hasIncrementedCounterBooleanStateas recomputed, but not as modified, since it resolves to the same value. - As a result and expected, only show
counterStateas modified. - As expected, show the component not re-rendering.
Here's a GIF showing the above:

We suspect this is also related to #1698