- Start Date: 2023-12-13
- RFC PR: #257
- React Issue: (leave this empty)
This RFC proposes a new hook useIsolation(() => …) to allow creating a sub-hook that can compute expensive values in isolation and can memoize its returned value.
A simple equivalent would be to introduce a new hook that would behave like useMemo, but in which you can also use other hooks.
Let’s say you have a context that is a bit massive, but you just want to listen to 1 value foo in it and only re-render when it changes, you could do something like this:
const MyComponent = () => {
const foo = React.useIsolation(() => {
const context = React.useContext(CustomHeavyContext);
return context.foo;
});
};But this would also work with any kind of state / values:
const MyComponent = () => {
const bar = React.useIsolation(() => {
const allSearchParams = useSearchParams();
return allSearchParams.get("bar");
});
};This topic is mostly for performance reasons. A few of other RFCs are proposing solutions to solve this:
When working with components, you can bail out of re-renders with React.memo / shouldComponentUpdate().
And given this tree: <A><B/></A>, when A changes, B may not re-render.
But when working with hooks, if you have 2 hooks useA and useB (used within useA), when useB re-renders, there is no way to bail out from its renders in useA.
Note
The goal of this RFC is not to provide a way for useB to not re-render when useA re-renders (even if it’s mentioned a bit with dependencies later), but to not always have to re-render useA when useB re-renders.
This is an issue for large codebases that can share a lot of hooks (without having the ability of auditing all of them).
Or for libraries like react-router-dom that expose large objects (the router state) and where users want to only register for changes that matter to them.
This hook is inspired by both React.memo where you can provide a areEqual function as a 2nd parameter, and by useMemo.
When you want to make a piece of code run in isolation* from its parent component/hook, you can use useIsolation.
This hook takes 2 parameters:
- Using MDN’s notation:
useIsolation(callback, [deps]) - Using TS’s notation:
useIsolation<T>(callback: () => T, deps?: ReadonlyArray<unknown>)
*: this wouldn’t strictly run in isolation, as you may define dependencies to ease the communication between the parent scope and the isolated one.
The way this would work in pseudo-code is this way:
Warning
I don’t know React internals so I’m going to make some assumptions
- During the initial mount, call the
callbackwithin its parent scope - Create a new internal call scope (like a component)
- If the
callbackuses hooks likeuseState,useContext, etc., bind them to this call scope instead of its parent scope - If there are dependencies defined, store then in internal slots (like with
useMemooruseCallback) saved on the parent scope - Store the return value of the
callbackin a internal slot in the parent scope - If there are any updates in any of the hooks defined within the call scope (aka within the
callback), re-compute thecallbackwithin its parent scope (just like whenuseMemorecomputes), and compare the return value in previous internal slot, if it’s the same, do nothing, if it changes, update the internal slot and re-render the parent scope. - If there are any updates in the parent scope, check if any dependencies have changed (if no dependencies are set, recompute on all updates), re-compute the
callbackwithin its parent scope (just like whenuseMemorecomputes), and compare the return value in previous internal slot, if it’s the same, do nothing, if it changes, update the internal slot and re-render the parent scope.
As this can accept optional dependencies, the question of "what to do if there is an update in the parent component/hook?" should be tackled.
This hook would work like any other hook and follow the rule of hooks. And as it creates a new call scope, this doesn’t break the rule of hooks per se (as sub-hooks would not always be called), as the isolated scope would behave like a sub-component.
This hook should only be available in client components, but not in RSCs.
One thing to note: the callback doesn’t have to be stable: just like the reducer in React.useReducer, when there is an update, React should just use its current definition.
const MyComponent = () => {
const [index, setIndex] = React.useState(0);
const [other, setOther] = React.useState(0);
const fooWithIndex = React.useIsolation(function isolated() {
const context = React.useContext(CustomHeavyContext);
return context.foo + index;
});
};With this piece of code,
- if
indexgets updated,isolated()will have to be re-computed - if
othergets updated,isolated()will have to be re-computed - if
CustomHeavyContextgets updated,isolated()will have to be re-computed - if
MyComponentre-renders for other reason (its parent was updated too for instance),isolated()will have to be re-computed
const MyComponent = () => {
const [index, setIndex] = React.useState(0);
const [other, setOther] = React.useState(0);
const fooWithIndex = React.useIsolation(
function isolated() {
const context = React.useContext(CustomHeavyContext);
return context.foo + index;
},
[index]
);
};With this piece of code,
- if
indexgets updated,isolated()will have to be re-computed - if
othergets updated,isolated()won’t have to be re-computed - if
CustomHeavyContextgets updated,isolated()will have to be re-computed
An example with more realistic code with a real context, props, states
const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() });
const MyComponent = (props) => {
const [otherState] = React.useState({});
const [arr, setArr] = React.useState([]);
const concatString = React.useIsolation(() => {
const context = React.useContext(CustomHeavyContext);
return [...context.foo, ...arr, ...props.otherArr].join(',');
}, [arr, props.otherArr]);
};In this example: concatString will only be recomputed if:
CustomHeavyContextchanges,arrchanges,props.otherArrchanges.
But not if otherState or other props change.
And if CustomHeavyContext changes but CustomHeavyContext.foo doesn’t, concatString will indeed be recomputed, but the new value will be stable (as concatString is a string). So MyComponent won’t re-render.
const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() });
const MyComponent = (props) => {
const [otherState] = React.useState({});
const [arr, setArr] = React.useState([]);
const concatArr = React.useIsolation(() => {
const context = React.useContext(CustomHeavyContext);
return React.useMemo(() => {
return [...context.foo, ...arr, ...props.otherArr];
}, [context.foo, arr, props.otherArr]);
}, [arr, props.otherArr]);
// Note: this ^ dep array here is optional as even if `useIsolation` re-runs at each render of `MyComponent`,
// `arr` & `props.otherArr` are already in the useMemo's dependencies, so React will keep the memoized value within the `useIsolation`
// and return a stable variable either way
};Here we need to use useMemo and as computing concatenation will re-generate a new array every time, even if no array in it doesn’t change.
In this section, some pieces of code will be displayed. They won’t be optimal in order to represent what we could find in existing codebases (as not everything can be refactored, often devs have to work with non-optimal code).
Imagine that we have those hooks:
const useLongPoll = (url, delay) => {
const [id, setId] = React.useState(0);
React.useEffect(() => {
const intervalId = setInterval(() => setId(id => id + 1), delay);
return () => clearInterval(intervalId);
}, []);
const [status, setStatus] = React.useState();
React.useEffect(() => {
const controller = new AbortController();
fetchStatus(url, { signal: controller.signal }).then(result => setStatus(result));
return () => controller.abort();
// Trigger a re-fetch every so often
}, [id]);
return status;
}
const MyComponent = () => {
// Fetch the status every 1s
const status = useLongPoll('/status', 1000);
if (!status) {
return null;
}
return <Card status={status}>{getContent(status)}</Card>
}useLongPoll isn’t optimal as it creates a re-render every <delay>ms. But this may be in one of the dependencies a code base is using so devs may not have the ability of changing that.
This means that MyComponent will be re-executed every second (or so) even if the status didn’t change. useIsolation could fix that:
const MyComponent = () => {
// Fetch the status every 1s
const status = useIsolation(() => useLongPoll('/status', 1000));
if (!status) {
return null;
}
return <Card status={status}>{getContent(status)}</Card>
}Now MyComponent only re-renders when the status actually changed.
Could using useIsolation lead to performance issues if it’s used without dependencies, or with wrong dependencies?
Ideally it shouldn’t, and when following the pseudo-code, there is no reason why it should. Let me explain: the worse-case scenario for performances would be to set no dependencies. In this situation we have 2 possibilities:
- the returned value isn’t stable (we re-generate a new object for instance at every recomputation)
- the returned value is stable
For 1., it could be for example this case:
const MyComponent = () => {
const notStable = React.useIsolation(() => {
return {};
});
};And in situation, it’d like just like if we were doing this code instead (with a bit of over-head for the memoization / internal slots):
const MyComponent = () => {
const notStable = {};
};So it should be okay: it won’t cause performance regressions (at least no dramatic ones), and won’t trigger new re-renders (compared to how MyComponent would already behave without useIsolation).
For 2., it could be for example this case:
const MyComponent = () => {
const stable = React.useIsolation(() => {
return React.useContext(MyContext).foo;
});
};As the returned value is stable (because React.useContext(MyContext) will already return the same value / pointer as long as the context didn’t change), it’ll be safe to be used in the render cycle, and even in dependencies of other hooks. Even if MyComponent re-renders due to external factors, stable won’t change, so it shouldn’t lead to performance issues.
TL;DR: even if no dependencies are set, performances shouldn’t be an issue, and setting them could just further improve performances.
The base principle of this new hook is to be able to create new call scope (aka component-like scopes or hooks within hooks).
But this may be a huge change in React’s internals.
As this is deeply related to this "component-like scope", it’s also impossible to polyfill / re-create on the user world and has to be implemented within React (I may be wrong on this).
This hook will also only be available in client code, and not in RSC.
One element I didn’t mention is that if useIsolation uses variables from the parent scope with the wrong dependencies, the hook won’t re-render as expected. As mentioned in Settings no dependencies or settings the wrong dependencies, as it should be fine to not set dependencies at all, maybe we can remove them. But I feel that they would be a nice addition as you can control re-renders with even finer control.
As mentioned before, useContextSelector proposed in #119 is a good substitute proposal. But this proposal is more generic as it can be also used with any kind of state / variable (see Wrapping existing hooks for perf optimizations only.
#168 is a similar RFC for the same hook: useIsolation. But this other RFC was closed as it was opened as an issue and not a PR. And this one adds the concept of dependencies to it (otherwise it should be similar).
As this is a new feature, no need to do a breaking change / introduce a new major / do codemods.
It can be released in a minor version.
The dependency array makes it really close to already existing hooks like useMemo / useCallback / useEffect.
Also this perfectly fits those already existing hooks as it could be built on top of them, so no new patterns to learn. And the previous best-practices can still be applied. It also follows the same rule of hooks as usual.
For new React developers, it could be taught as a hook to boost performance, like useMemo: it should work without, but this can prevent unnecessary re-renders.
The only new paradigm is that as hooks will be defined within the callback, we’ll need to teach developers that this doesn’t break the rule of hooks (as those would run kind of like in another component).
Is this concurrent-compliant?
Otherwise, I don’t really know.