Back to Fluentui

RFC: Changes to `useContextSelector()`

docs/react-v9/contributing/rfcs/react-components/context-selector-tearing.md

4.40.2-hotfix219.5 KB
Original Source

RFC: Changes to useContextSelector()

@layeshifter

<!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->

Summary

We offer a hook named useContextSelector() (in the @fluentui/react-context-selector) package. This hook allows you to select a specific value from the context using a selector function. The hook prevents the component from re-rendering until the selected value changes.

jsx
function ListItem(props) {
  // 😎 Bail out if the selected value does not change
  const isActive = useContextSelector(ListValueContext, activeValue => activeValue === props.value /* item value */);

  return (
    <div>
      {props.value}, isActive: {isActive.toString()}
    </div>
  );
}

πŸ’‘ This works similarly to reselect for Redux.

This enables us to create collection components, such as List and ListItem, that are more efficient by avoiding unnecessary re-renders:

tsx
function App() {
  const [activeValue, setActiveValue] = React.useState(1);
  // πŸ’‘ React.memo() could be also used there to bail out from re-renders
  const children = React.useMemo(
    () => (
      <>
        <ListItem value={1} />
        <ListItem value={2} />
        <ListItem value={3} />
      </>
    ),
    [],
  );

  return (
    <>
      <List activeValue={activeValue}>{children}</List>
      <button onClick={() => setActiveValue(2)}>Set value to 2</button>
    </>
  );
}

In this example, only the items with value={1} and value={2} will re-render when the active value changes.

Compared to React.useContext()

Using React.useContext() in this case will cause a re-render for every subscriber whenever the context changes:

jsx
function ListItem(props) {
  // πŸ‘Ž Re-render on every context change
  const activeValue = React.useContext(ListValueContext);
  const isActive = activeValue === props.value;

  return (
    <div>
      {props.value}, isActive: {isActive.toString()}
    </div>
  );
}

Current problem

useContextSelector() is not a part of the React.js core, there have been RFCs about it (reactjs/rfcs#119: RFC: Context selectors & reactjs/rfcs#150: RFC: Speculative Mode), however none of them were implemented.

[!IMPORTANT]
There is a newly added API under a feature flag (unstable_useContextWithBailout, facebook/react#30407: Add unstable context bailout for profiling) with an uncertain future that achieves the same functionality. However, it operates on React's internal level and could do it properly.

In the same time, our implementation of useContextSelector() relies solely on public APIs and has some limitations, such as issues with tearing and additional re-renders to avoid it.

What is tearing?

Tearing is a situation when a component is rendered with a new value, but the old value is still in the process of being rendered. This can lead to a situation where the components are rendered with a mix of old and new values.

To gain a proper understanding of the topic, please review the following before proceeding:

The quote above sounds scary, right? Well, it is, but it's not as bad as it seems. We will never have UIs with stale values once the rendering is finished. In the worst case, we will end up with "Level 1":

Level 1

The bare minimum support is to just allow the UI to temporarily tear. With this level of support, application developers can use the library with concurrent features, but may temporarily see inconsistent UIs in their app.

https://github.com/reactwg/react-18/discussions/70

Where we are now?

@fluentui/react-context-selector is a copy-paste of an older version of use-context-selector package. However, there are some expectations that are not met:

The goal of this library is not performance.

https://github.com/dai-shi/use-context-selector/issues/100#issuecomment-1412847282

The motivation of this library is to make it compatible (as much as possible in userland) with concurrency.

https://github.com/dai-shi/use-context-selector/issues/100#issuecomment-1411240604

use-context-selector indeed is passing the tests from will-this-react-global-state-work-in-concurrent-rendering repository. But at what price?

Behavioral benchmark

To benchmark possible options, I've created a simple app that contains basic scenarios similar to those in Fluent UI.

Strict Mode is disabled for all scenarios.

  • Scenario 1: List & ListItems (no additional memoization), expected re-renders:
    • 1 for List
    • N for ListItems (N = number of items) on every update
  • Scenario 2: List & ListItems (with memoization), expected re-renders:
    • 1 for List
    • 2 for ListItems (1 item becomes active, 1 item becomes inactive) on every update

useContextSelector() & re-renders

This implementation is passing the tearing test βœ…

Example sandbox: https://stackblitz.com/edit/vitejs-vite-e8vsk7

Test results
  • Scenario 1, initial render: 1 List + 2N ListItems 🚨

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>
  • Scenario 1, update render: 1 List + 2N ListItems 🚨

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    render:ListItem {active: false, value: '1'}
    render:ListItem {active: true, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>
  • Scenario 2, initial render: 1 List + 2N ListItems 🚨

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>
  • Scenario 2, update render: 1 List + N ListItems 🚨

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: false, value: '1'}
    render:ListItem {active: true, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>

Looks like it doesn't work as expected? Indeed, but it's intentional:

If that's the case, it's expected behavior, React 18 disables useReducer early bailout and it's how it works.

https://github.com/dai-shi/use-context-selector/issues/100#issuecomment-1407291726

@fluentui/react-context-selector & re-renders (we use this)

This implementation is passing the tearing test βœ…

Example sandbox: https://stackblitz.com/edit/vitejs-vite-h3mmon

We didn't like the behavior described above (and honestly, it looks more like a bug than something intentional). Our fork uses useState() which has a bailout mechanism, see microsoft/fluentui#30951.

The shim for useSyncExternalStore() also uses useState() instead of useReducer().

https://github.com/facebook/react/blob/9eb288e6579333612ed736c4e088669daf90a076/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L90

Test results
  • Scenario 1, initial render: 1 List + N ListItems βœ…

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>
  • Scenario 1, update render: 1 List + N+2 ListItems 🚨 (see "Exploration" for details)

    <details> <summary>Log</summary>
    render:List
    /* re-rendered all items, but with _stale_ values */
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    /* re-rendered matched items with proper values */
    render:ListItem {active: false, value: '1'}
    render:ListItem {active: true, value: '2'}
    
    </details>
  • Scenario 2, initial render: 1 List + N ListItems βœ…

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: true, value: '1'}
    render:ListItem {active: false, value: '2'}
    render:ListItem {active: false, value: '3'}
    render:ListItem {active: false, value: '4'}
    
    </details>
  • Scenario 2, update render: 1 List + 2 ListItems βœ…

    <details> <summary>Log</summary>
    render:List
    render:ListItem {active: false, value: '1'}
    render:ListItem {active: true, value: '2'}
    
    </details>

Note on glitchy behavior

It could be noticed that in the update scenario, three items are re-rendered instead of two 😦 Even though the value of one item never changes, it still gets re-rendered. The logic is correct; it's some kind of issue with the useState() bailout mechanism.

render:List
render:ListItem {active: false, value: '1'}
render:ListItem {active: true, value: '2'}
render:List
render:ListItem {active: false, value: '1'} # πŸ’£ should not be re-rendered
render:ListItem {active: false, value: '2'}
render:ListItem {active: true, value: '3'}
render:List
render:ListItem {active: false, value: '2'} # πŸ’£ should not be re-rendered
render:ListItem {active: false, value: '3'}
render:ListItem {active: true, value: '4'}

Exploration

The good news is that our fork performs better than the original implementation. The bad news is that one scenario still does not work as expected.

Let's dig into why "Scenario 1, update render" is not working as anticipated. We have the following render loop:

- render:List (activeValue: 2)
  // re-rendered all items with stale value
- render:ListItem* (activeValue: 1)
- useLayoutEffect:List (activeValue: 2)
  // re-rendered matched items with proper value
- render:ListItem* (activeValue: 2)

Let's break it down:

The question that immediately arises is: why do we re-render all items with a stale value? Well, we can, but it will cause issues with tearing. Or not?! πŸ€”

Tearing issues

The test (from will-this-react-global-state-work-in-concurrent-rendering) renders a set of items (similarly to the ListItem component) and updates a value:

  • one counter is updated immediately ("Main" section)
  • other counters are updated with a delay via startTransition() ("Counters" section)

Deployed examples of the test:

To reproduce the issue, we need to:

  • Open a deployed example
  • Click on the "Show counter in transition" button
  • Click on the "Increment count in transition" button
  • Observe the logs in the console ("count mismatch" error) πŸ’₯

If a breakpoint is set on console.error() in the test we can notice tearing in action:

  • "Main" counter is updated immediately and has "1"
  • "Counters" are updated with a delay and have "0" (stale value)

And it seems to be a problem? πŸ€” Well, it seems that tearing is by design here: we always render the List first and then the ListItems. With the current approach, tearing appears to be unavoidable. For example, it can occur when consumers have controlled state, which is why not only the "Main" counter but also the "value" can go out of sync.

Okay, but why it's not an issue with X, Y, Z?

React 18 has useSyncExternalStore() for synchronizing state with an external store β€” shouldn't that help here? No, it will not as the usage pattern is different.

Unlike other libraries that manage state outside of React, we need to propagate state updates within React itself. For example, if our components were never controlled (i.e., if state could not be controlled), we could directly call actions on the store within ListItem and avoid consuming/updating the state from List.

tsx
import { useSyncExternalStore } from 'use-sync-external-store/shim';
const List = () => {
  const [store] = React.useState(() => createStore());

  /**
   * πŸ’‘ `List` does not thing with the value, it's only a provider
   */

  return <ListContext.Provider value={store}></ListContext.Provider>;
};

const ListItem = ({ value }) => {
  const store = useListStore();

  const isActive = useSyncExternalStore(store.subscribe, store => store.isActive(value));
  const onClick = () => store.setActiveItem(value);

  return (
    <button className={isActive ? 'listitem-active' : 'listitem'} onClick={onClick}>
      {value}
    </button>
  );
};

However, as state could be controlled via props, we need to propagate the value in render:

tsx
const List = ({ value }) => {
  const [store] = React.useState(() => createStore());

  React.useLayoutEffect(() => {
    store.setValue(value);
  }, [value]);

  return <ListContext.Provider value={store}></ListContext.Provider>;
};

And this is the point where tearing seems to be unavoidable by design due to our current usage pattern.

POC

Example: https://stackblitz.com/edit/vitejs-vite-3gsvqu

Example also contains additional scenarios for useTranstion() and useDeferredValue() hooks. The goal is to check if tearing is an issue with these hooks.

Note: POC is based on useSyncExternalStore() shim, but useSyncExternalStore() could be also used directly.

  • There are no visible issues
  • "Scenario 1, update render" works as expected
  • Passes unit & visual tests (microsoft/fluentui#32226)
  • Passes all tests in the product (Teams)

While there might be tearing, the final result will not contain stale values:

Level 1

With this level of support, application developers can use the library with concurrent features, but may temporarily see inconsistent UIs in their app.

https://github.com/reactwg/react-18/discussions/70

Can we avoid useLayoutEffect?

Not really. There will still be situations where we need to propagate the value within effects, particularly when some consumer components are memoized and skip re-renders. If we were to use useEffect instead of useLayoutEffect, we might encounter cases where the value isn’t propagated in time, making tearing more apparent. Additionally, using useEffect breaks unit tests in Fluent itself and products πŸ˜₯

Decision

Option A: Do nothing (safe)

It’s simple as that πŸ™Œ

It works β€” it's not perfect, but it functions. The problem arises in a single scenario and depending on the use case, it might be acceptable.

We can also keep an eye on unstable_useContextWithBailout and look forward to its eventual implementation in React.

https://github.com/facebook/react/pull/30407

Rejected options

Option B: Use useSyncExternalStore() (probably safe)

Switch to useSyncExternalStore() available in React 18 (and the shim for earlier versions). This will resolve the glitch issue and simplify the code, code changes are in microsoft/fluentui#30950.

Note: "Scenario 1, update render" will still not work as expected, so no real improvement here.

Option C: Propagate the value in render (risky)

Use "Option B" or POC (which is based on useSyncExternalStore() shim) and propagate the value in render:

tsx
function Provider() {
  // ⬇️ `value` is passed in render, so components rendered
  //     in the same cycle can consume actual value
  store.value = props.value;

  React.useLayoutEffect(() => {
    store.value = value;
    store.notify();
  });
}

It will function as expected in terms of behavior, but might introduce additional issues with tearing.