docs/react-v9/contributing/rfcs/react-components/convergence/context-values.md
@layershifter @bsunderhus
This RFC affects converged components and proposes multiple options detailing how to handle memoized context values for React Contexts.
There are cases when we need to pass some props or parts of state to child components, for this purpose we are using React Context. To avoid unnecessary updates during components rerenders we are using either context selector approach or memoizing context value.
// 💡 an example of memoization approach
function Baz(props) {
const { inline, size } = props;
const value = React.useMemo(() => ({ inline, open }), [inline, open]);
// consumers of "SampleContext" will be notified only when "value" changes
return <SampleContext.Provider value={value} />;
}
Currently, a few components (at least Accordion, AccordionHeader, AccordionItem) are defining a value for React Context on its state to use it in render* functions:
// ⚠ simplified example
function useBazState(props) {
const { inline, size } = props;
const [open, setOpen] = React.useState();
const contextValue = React.useMemo(() => ({ inline, open }), [inline, open]);
const state = {
inline,
size,
open,
// 👇 "contextValue" is a part of component's state
contextValue,
};
return state;
}
function renderBaz(state) {
// 👇 "value" is memoized, consumer components will not rerender without need
return (
<SampleContext.Provider value={state.contextValue}>
<div />
</SampleContext.Provider>
);
}
The problem comes from multiple places:
contextValue is not a part of state, it's useless from component's perspectiveThe last item is a really serious problem as customers should be aware about our implementation details 🕵️
// 🛠 In this example I would like to override `size` based on a condition
function useSuperBazState(props) {
const state = useBazState(props);
// ❌ is broken because `state.contextValue.size` remains the same
if (props.reallyBig) {
state.size = 'xxl';
}
// ❌ is broken because `state.contextValue` is a stable object and will not trigger
// a rerender
if (props.reallyBig) {
state.size = 'xxl';
state.contextValue.size = 'xxl';
}
// ✅ only this solution will work as expected
state.size = 'xxl';
state.contextValue = React.useMemo(
() => ({
...contextValue,
...(props.reallyBig && { size: 'xxl' }),
}),
[state.contextValue, props.reallyBig],
);
}
Ideally, we can simply remove contextValue from state and move it into render* function. And it's already done in few components:
function renderBaz(state) {
const { open, inline } = state;
// 💥 this violates rules of hooks: hooks should be called inside components or other hooks
const contextValue = React.useMemo(() => ({ inline, open }), [inline, open]);
return (
<SampleContext.Provider value={contextValue}>
<div />
</SampleContext.Provider>
);
}
The problem is this approach violates Rules of Hooks as we are calling React.useMemo() inside a function. Actually, it's the reason why contextValue is being declared in use*State() hooks.
It's clear that we would like to avoid having values for React Context in state objects and we don't want to violate Rules of Hooks 🤔
Another option is to modify a signature of render functions to include context values as the second param and compute them inside component.
function FooComponent() {
const state = useFooState()
+ const barContextValue = React.useMemo(/* ... */)
+ const bazContextValue = React.useMemo(/* ... */)
- return renderFoo(state)
+ return renderFoo(state, { bar: barContextValue, baz: bazContextValue })
}
// ---
-function renderFoo(state) {
- return <SampleContext.Provider value={state.bazContextValue} />
+function renderFoo(state, contextValues) {
+ return <SampleContext.Provider value={contextValues.baz} />
}
To improve experience for customers and ourselves we can extract creation of values in a separate hook:
function useFooContextValues() {
const barContextValue = React.useMemo(/* ... */);
const bazContextValue = React.useMemo(/* ... */);
return { bar: bazContextValue, baz: bazContextValue };
}
function FooComponent() {
const state = useFooState();
const contextValues = useFooContextValues(state);
return renderFoo(state, contextValues);
}
function renderFoo(state, contextValues) {
return <SampleContext.Provider value={contextValues.baz} />;
}
render functions hooksWe can transform existing render functions to be React hooks:
-function renderBaz(state) {
+function useRenderBaz(state) {
const { open, inline } = state;
// ✅ now we can use hooks
const contextValue = React.useMemo(() => ({ inline, open }), [inline, open]);
// ...
}
It's one of the easiest options, but it could cause issues in future as it breaks separation of concerns. For example, other hooks can be also called now in useRender:
function useRenderBaz(state) {
const { open, inline } = state;
// 💣 other hooks can be also declared there
React.useEffect(() => {
// ...
}, [open]);
}
useContextSelector() everywhereFor context-selector memoization is not required as it will happen in consumers. In this case we don't need to transform render functions to React hooks.
function renderBaz(state) {
const { open, inline } = state;
return (
<SampleContext.Provider value={{ inline, open }}>
<div />
</SampleContext.Provider>
);
}
// ---
function useBazItem() {
// ✅ only if "open"/"inline" will change a rerender will be triggered
const open = useContextSelector(SampleContext, value => value.open);
const inline = useContextSelector(SampleContext, value => value.inline);
}
👍 Allows to keep existing API design
👎 Even if it's enough performant option, it will be anyway slower than React.useContext() as we are evaluating additional code during render cycles
👎 (potentially) This implementation is tested with React's Concurrent mode, but still could cause issues with React 18
👎 Sooner or later useContextSelector will be implemented in React's core (facebook/react#20646) and it could have different API than our implementation