docs/react-v9/contributing/rfcs/react-components/context-selector-tearing.md
useContextSelector()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.
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:
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.
React.useContext()Using React.useContext() in this case will cause a re-render for every subscriber whenever the context changes:
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>
);
}
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.
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.
@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?
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.
List & ListItems (no additional memoization), expected re-renders:
ListListItems (N = number of items) on every updateList & ListItems (with memoization), expected re-renders:
ListListItems (1 item becomes active, 1 item becomes inactive) on every updateuseContextSelector() & re-rendersThis implementation is passing the tearing test β
Example sandbox: https://stackblitz.com/edit/vitejs-vite-e8vsk7
Scenario 1, initial render: 1 List + 2N ListItems π¨
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'}
Scenario 1, update render: 1 List + 2N ListItems π¨
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'}
Scenario 2, initial render: 1 List + 2N ListItems π¨
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'}
Scenario 2, update render: 1 List + N ListItems π¨
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'}
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 usesuseState()instead ofuseReducer().
Scenario 1, initial render: 1 List + N ListItems β
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'}
Scenario 1, update render: 1 List + N+2 ListItems π¨ (see "Exploration" for details)
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'}
Scenario 2, initial render: 1 List + N ListItems β
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'}
Scenario 2, update render: 1 List + 2 ListItems β
render:List
render:ListItem {active: false, value: '1'}
render:ListItem {active: true, value: '2'}
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'}
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:
List's children with a stale (from a previous update) value:
tsconst { value: { current: value }, // β οΈ currently `value` equals to `1` // ... } = contextValue; // π‘ `contextValue` is a stable object const selected = selector(value); // `selector` runs with a _stale_ value
List executes useLayoutEffect and propagates the new value to the children via listeners:
tsuseIsomorphicLayoutEffect(() => { valueRef.current = props.value; // `props.value` equals to `2` // ... listeners.forEach(listener => { listener([versionRef.current, props.value]); }); }, [props.value]);
ListItem (in useContextSelector) is triggered and re-renders the component if needed (and it's needed):
tsconst dispatch = ( payload: | undefined // undefined from render below | readonly [ContextVersion, Value], // from provider effect ) => { setState(prevState => { /* ... */ }); };
ListItems again with a proper value:
tsconst { value: { current: value }, // β currently `value` equals to `2` // ... } = contextValue; // π‘ `contextValue` is a stable object const selected = selector(value); // `selector` runs with a _stale_ value
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?! π€
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:
startTransition() ("Counters" section)Deployed examples of the test:
@fluentui/react-context-selector: https://stackblitz.com/edit/stackblitz-starters-accpsb- POC with passing a value in render: https://stackblitz.com/edit/stackblitz-starters-ycw2at
To reproduce the issue, we need to:
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
ListItemand avoid consuming/updating the state fromList.tsximport { 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:tsxconst 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.
Example: https://stackblitz.com/edit/vitejs-vite-3gsvqu
Example also contains additional scenarios for
useTranstion()anduseDeferredValue()hooks. The goal is to check if tearing is an issue with these hooks.Note: POC is based on
useSyncExternalStore()shim, butuseSyncExternalStore()could be also used directly.
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.
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 π₯
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_useContextWithBailoutand look forward to its eventual implementation in React.
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.
Use "Option B" or POC (which is based on useSyncExternalStore() shim) and propagate the value in render:
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.