From 69a48f5707ec7cdaa3dbcbe27023a676184a6d12 Mon Sep 17 00:00:00 2001 From: Ayc0 Date: Wed, 13 Dec 2023 00:12:28 +0000 Subject: [PATCH 01/20] RFC: useIsolation --- text/0000-use-isolation.md | 157 +++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 text/0000-use-isolation.md diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md new file mode 100644 index 00000000..dc1b65eb --- /dev/null +++ b/text/0000-use-isolation.md @@ -0,0 +1,157 @@ +- Start Date: 2023-12-13 +- RFC PR: (leave this empty) +- React Issue: (leave this empty) + +# Summary + +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. + +# Basic example + +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: + +```jsx +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: + +```jsx +const MyComponent = () => { + const bar = React.useIsolation(() => { + const allSearchParams = useSearchParams(); + return allSearchParams.get("bar"); + }); +}; +``` + +# Motivation + +This topic is mostly for performance reasons. A few of other RFCs are proposing solutions to solve this: + +- `useIsolation` in https://github.com/reactjs/rfcs/issues/168, +- `useContextSelector` in https://github.com/reactjs/rfcs/pull/119. + +When working with components, you can bail out of re-renders with `React.memo` / `shouldComponentUpdate()`. + +And given this tree: ``, when `A` changes, `B` may not rerender. +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 neither `useA`. + +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. + +# Detailed design + +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(callback: () => T, deps?: ReadonlyArray)` + +\*: this wouldn’t strictly run in isolation, as you may define dependencies to ease the communication between the parent scope and the isolated one. + +## Pseudo-code mechanism + +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 + +1. During the initial mount, call the `callback` within its _parent scope_ +2. Create a new internal _call scope_ (like a component) +3. If the `callback` uses hooks like `useState`, `useContext`, etc., bind them to this _call scope_ instead of its parent scope +4. If there are dependencies defined, store then in _internal slots_ (like like with `useMemo` or `useCallback`) saved on the _parent scope_ +5. Store the return value of the `callback` in a _internal slot_ in the _parent scope_ +6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback`, and store the return value in same _internal slot_. +7. 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 `callback`, and store the return value in same _internal slot_. + +## Details on the design + +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 say (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. + +## Code examples + +### Without dependencies + +```jsx +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 `index` gets updated, `isolated()` will have to be re-computed +- if `other` gets updated, `isolated()` will have to be re-computed +- if `CustomHeavyContext` gets updated, `isolated()` will have to be re-computed + +### With dependencies + +```jsx +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 `index` gets updated, `isolated()` will have to be re-computed +- if `other` gets updated, `isolated()` **won’t** have to be re-computed +- if `CustomHeavyContext` gets updated, `isolated()` will have to be re-computed + +# Drawbacks + +The base principle of this new hook is to be able to create new _call scope_ (aka component-like scopes). +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. + +# Alternatives + +As mentioned before, `useContextSelector` proposed in https://github.com/reactjs/rfcs/pull/119 is a good substitute proposal. But this proposal is more generic as it can be also used with any kind of state / variable. + +# Adoption strategy + +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. + +# How we teach this + +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). + +# Unresolved questions + +I don’t really know. From 91a5a77fc81ac30b4aea53947650c7fcc63764db Mon Sep 17 00:00:00 2001 From: Ayc0 Date: Wed, 13 Dec 2023 00:21:14 +0000 Subject: [PATCH 02/20] =?UTF-8?q?RFC:=20useIsolation=20=E2=80=93=20add=20b?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- text/0000-use-isolation.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index dc1b65eb..81588b50 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -39,22 +39,21 @@ This topic is mostly for performance reasons. A few of other RFCs are proposing When working with components, you can bail out of re-renders with `React.memo` / `shouldComponentUpdate()`. -And given this tree: ``, when `A` changes, `B` may not rerender. -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 neither `useA`. +And given this tree: ``, when `A` changes, `B` may not rerender.
+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 neither `useA`. -This is an issue for large codebases that can share a lot of hooks (without having the ability of auditing all of them). +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. # Detailed design 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`. +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(callback: () => T, deps?: ReadonlyArray)` +- Using MDN’s notation: `useIsolation(callback, [deps])` +- Using TS’s notation: `useIsolation(callback: () => T, deps?: ReadonlyArray)` \*: this wouldn’t strictly run in isolation, as you may define dependencies to ease the communication between the parent scope and the isolated one. @@ -126,7 +125,7 @@ With this piece of code, # Drawbacks -The base principle of this new hook is to be able to create new _call scope_ (aka component-like scopes). +The base principle of this new hook is to be able to create new _call scope_ (aka component-like scopes).
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). @@ -139,7 +138,7 @@ As mentioned before, `useContextSelector` proposed in https://github.com/reactjs # Adoption strategy -As this is a new feature, no need to do a breaking change / introduce a new major / do codemods. +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. # How we teach this From 6dd165263a61094e6642919b1eb8fe15537e6018 Mon Sep 17 00:00:00 2001 From: Ayc0 Date: Wed, 13 Dec 2023 00:25:49 +0000 Subject: [PATCH 03/20] =?UTF-8?q?RFC:=20useIsolation=20=E2=80=93=20add=20n?= =?UTF-8?q?ote=20about=20concurrent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- text/0000-use-isolation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 81588b50..9bb3a9d2 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -153,4 +153,6 @@ The only new paradigm is that as hooks will be defined within the `callback`, we # Unresolved questions -I don’t really know. +Is this concurrent-compliant? + +Otherwise, I don’t really know. From e9ee5e71c30e5ab6031fbd3aa0250cb5cbd419aa Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 09:12:35 +0100 Subject: [PATCH 04/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 9bb3a9d2..82d42cee 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -69,8 +69,8 @@ The way this would work in pseudo-code is this way: 3. If the `callback` uses hooks like `useState`, `useContext`, etc., bind them to this _call scope_ instead of its parent scope 4. If there are dependencies defined, store then in _internal slots_ (like like with `useMemo` or `useCallback`) saved on the _parent scope_ 5. Store the return value of the `callback` in a _internal slot_ in the _parent scope_ -6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback`, and store the return value in same _internal slot_. -7. 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 `callback`, and store the return value in same _internal slot_. +6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. +7. 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 `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. ## Details on the design From 927f0091fcfc5317bf7397cd2bca8da4ea77fb17 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 09:38:38 +0100 Subject: [PATCH 05/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 50 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 82d42cee..5d8a55db 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -39,8 +39,8 @@ This topic is mostly for performance reasons. A few of other RFCs are proposing When working with components, you can bail out of re-renders with `React.memo` / `shouldComponentUpdate()`. -And given this tree: ``, when `A` changes, `B` may not rerender.
-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 neither `useA`. +And given this tree: ``, 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`. 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. @@ -67,7 +67,7 @@ The way this would work in pseudo-code is this way: 1. During the initial mount, call the `callback` within its _parent scope_ 2. Create a new internal _call scope_ (like a component) 3. If the `callback` uses hooks like `useState`, `useContext`, etc., bind them to this _call scope_ instead of its parent scope -4. If there are dependencies defined, store then in _internal slots_ (like like with `useMemo` or `useCallback`) saved on the _parent scope_ +4. If there are dependencies defined, store then in _internal slots_ (like with `useMemo` or `useCallback`) saved on the _parent scope_ 5. Store the return value of the `callback` in a _internal slot_ in the _parent scope_ 6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. 7. 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 `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. @@ -123,6 +123,48 @@ With this piece of code, - if `other` gets updated, `isolated()` **won’t** have to be re-computed - if `CustomHeavyContext` gets updated, `isolated()` will have to be re-computed +## Settings no dependencies or settings the wrong dependencies + +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: +1. the returned value isn’t stable (we re-generate a new object for instance at every recomputation) +2. the returned value is stable + +For 1., it could be for example this case: + +```jsx +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_): + +```jsx +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: + +```jsx +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. + # Drawbacks The base principle of this new hook is to be able to create new _call scope_ (aka component-like scopes).
@@ -132,6 +174,8 @@ As this is deeply related to this "component-like scope", it’s also impossible 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](#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 more finer control. + # Alternatives As mentioned before, `useContextSelector` proposed in https://github.com/reactjs/rfcs/pull/119 is a good substitute proposal. But this proposal is more generic as it can be also used with any kind of state / variable. From fb47d774281bd685b2427f98ee9f4d3320e5bf17 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:00:54 +0100 Subject: [PATCH 06/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 5d8a55db..2fd233e1 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -42,9 +42,13 @@ When working with components, you can bail out of re-renders with `React.memo` / And given this tree: ``, 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. + # Detailed design This hook is inspired by both `React.memo` where you can provide a `areEqual` function as a 2nd parameter, and by `useMemo`. @@ -100,6 +104,7 @@ With this piece of code, - if `index` gets updated, `isolated()` will have to be re-computed - if `other` gets updated, `isolated()` will have to be re-computed - if `CustomHeavyContext` gets updated, `isolated()` will have to be re-computed +- if `MyComponent` re-renders for other reason (its parent was updated too for instance), `isolated()` will have to be re-computed ### With dependencies From c17fa4ec5378645cfa0a0377d193b44e7859bda0 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:01:20 +0100 Subject: [PATCH 07/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 2fd233e1..2385f748 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -43,7 +43,7 @@ And given this tree: ``, 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. +> 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. From 0065479487e495cd158128290d523a4887c753bc Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:23:52 +0100 Subject: [PATCH 08/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 60 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 2385f748..49553566 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -6,6 +6,8 @@ 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. + # Basic example 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: @@ -30,6 +32,60 @@ const MyComponent = () => { }; ``` +## Advanced example + +An example with more realistic code with a _real_ context, props, states + +```jsx +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: +- `CustomHeavyContext` changes, +- `arr` changes, +- `props.otherArr` changes. + +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. + +
Similar example with a non-stable value + +```jsx +const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() }); + +const MyComponent = (props) => { + const [otherState] = React.useState({}); + + const [arr, setArr] = React.useState([]); + + const foo = React.useIsolation(() => { + return React.useContext(CustomHeavyContext).foo; + }, []); // Here the dependencies could have been fully avoided, as `React.useContext(CustomHeavyContext).foo` is by definition stable + + const concatArr = React.useMemo(() => { + return [...foo, ...arr, ...props.otherArr]; + }, [foo, arr, props.otherArr]); +}; +``` + +Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavyContext).foo, ...arr, ...props.otherArr]` would re-generate a new array every time, even if `React.useContext(CustomHeavyContext).foo` doesn’t change. + +
+ + + + # Motivation This topic is mostly for performance reasons. A few of other RFCs are proposing solutions to solve this: @@ -73,8 +129,8 @@ The way this would work in pseudo-code is this way: 3. If the `callback` uses hooks like `useState`, `useContext`, etc., bind them to this _call scope_ instead of its parent scope 4. If there are dependencies defined, store then in _internal slots_ (like with `useMemo` or `useCallback`) saved on the _parent scope_ 5. Store the return value of the `callback` in a _internal slot_ in the _parent scope_ -6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. -7. 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 `callback` within its parent scope (just like when `useMemo` recomputes), and store the return value in same _internal slot_. +6. If there are any updates in any of the hooks defined within the _call scope_ (aka within the `callback`), re-compute the `callback` within its parent scope (just like when `useMemo` recomputes), 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_. +7. 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 `callback` within its parent scope (just like when `useMemo` recomputes), 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_. ## Details on the design From be5b6b9af393ef65d42d331337df4d8a7e27dd03 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:32:58 +0100 Subject: [PATCH 09/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 113 +++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 49553566..ff5da5a8 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -32,60 +32,6 @@ const MyComponent = () => { }; ``` -## Advanced example - -An example with more realistic code with a _real_ context, props, states - -```jsx -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: -- `CustomHeavyContext` changes, -- `arr` changes, -- `props.otherArr` changes. - -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. - -
Similar example with a non-stable value - -```jsx -const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() }); - -const MyComponent = (props) => { - const [otherState] = React.useState({}); - - const [arr, setArr] = React.useState([]); - - const foo = React.useIsolation(() => { - return React.useContext(CustomHeavyContext).foo; - }, []); // Here the dependencies could have been fully avoided, as `React.useContext(CustomHeavyContext).foo` is by definition stable - - const concatArr = React.useMemo(() => { - return [...foo, ...arr, ...props.otherArr]; - }, [foo, arr, props.otherArr]); -}; -``` - -Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavyContext).foo, ...arr, ...props.otherArr]` would re-generate a new array every time, even if `React.useContext(CustomHeavyContext).foo` doesn’t change. - -
- - - - # Motivation This topic is mostly for performance reasons. A few of other RFCs are proposing solutions to solve this: @@ -184,6 +130,61 @@ With this piece of code, - if `other` gets updated, `isolated()` **won’t** have to be re-computed - if `CustomHeavyContext` gets updated, `isolated()` will have to be re-computed +## Advanced example + +An example with more realistic code with a _real_ context, props, states + +```jsx +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: +- `CustomHeavyContext` changes, +- `arr` changes, +- `props.otherArr` changes. + +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. + +
Similar example with a non-stable value + +```jsx +const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() }); + +const MyComponent = (props) => { + const [otherState] = React.useState({}); + + const [arr, setArr] = React.useState([]); + + const foo = React.useIsolation(() => { + return React.useContext(CustomHeavyContext).foo; + }, []); // Here the dependencies could have been fully avoided, as `React.useContext(CustomHeavyContext).foo` is by definition stable + + const concatArr = React.useMemo(() => { + return [...foo, ...arr, ...props.otherArr]; + }, [foo, arr, props.otherArr]); +}; +``` + +Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavyContext).foo, ...arr, ...props.otherArr]` would re-generate a new array every time, even if `React.useContext(CustomHeavyContext).foo` doesn’t change. + +
+ +## Wrapping existing hook for perf optimizations only + +**TO WRITE** + ## Settings no dependencies or settings the wrong dependencies Could using `useIsolation` lead to performance issues if it’s used without dependencies, or with wrong dependencies? @@ -228,7 +229,7 @@ TL;DR: even if no dependencies are set, performances shouldn’t be an issue, an # Drawbacks -The base principle of this new hook is to be able to create new _call scope_ (aka component-like scopes).
+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). @@ -250,7 +251,7 @@ 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. +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. From dff7fb2f86f897d9743e64b3242876cbe42da5b1 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:35:20 +0100 Subject: [PATCH 10/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index ff5da5a8..47aa8205 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -242,6 +242,8 @@ One element I didn’t mention is that if `useIsolation` uses variables from the As mentioned before, `useContextSelector` proposed in https://github.com/reactjs/rfcs/pull/119 is a good substitute proposal. But this proposal is more generic as it can be also used with any kind of state / variable. +https://github.com/reactjs/rfcs/issues/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). + # Adoption strategy As this is a new feature, no need to do a breaking change / introduce a new major / do codemods.
From a9e0c14a79a9406d8da8d5e7b52afb1eab6f67ac Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 10:37:32 +0100 Subject: [PATCH 11/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 47aa8205..116317a9 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -181,7 +181,7 @@ Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavy -## Wrapping existing hook for perf optimizations only +## Wrapping existing hooks for perf optimizations only **TO WRITE** @@ -240,7 +240,7 @@ One element I didn’t mention is that if `useIsolation` uses variables from the # Alternatives -As mentioned before, `useContextSelector` proposed in https://github.com/reactjs/rfcs/pull/119 is a good substitute proposal. But this proposal is more generic as it can be also used with any kind of state / variable. +As mentioned before, `useContextSelector` proposed in https://github.com/reactjs/rfcs/pull/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](#wrapping-existing-hooks-for-perf-optimizations-only). https://github.com/reactjs/rfcs/issues/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). From 4d9a57075a1329e54e04936b70d6dfc620d2864c Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 12:03:28 +0100 Subject: [PATCH 12/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 116317a9..8872249c 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -86,6 +86,8 @@ This hook would work like any other hook and follow the rule of hooks. And as it 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. + ## Code examples ### Without dependencies From 3db27236a993927113dd3b68a5b7b027f9a21630 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 12:15:39 +0100 Subject: [PATCH 13/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 52 +++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 8872249c..1c704ad2 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -185,7 +185,57 @@ Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavy ## Wrapping existing hooks for perf optimizations only -**TO WRITE** +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: + +```jsx +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 {getContent(status)} +} +``` + +`useLongPoll` isn’t optimal as it creates a re-render every ``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: + +```jsx +const MyComponent = () => { + // Fetch the status every 1s + const status = useIsolation(() => useLongPoll('/status', 1000)); + + if (!status) { + return null; + } + return {getContent(status)} +} +``` + +Now `MyComponent` only re-renders when the status actually changed. ## Settings no dependencies or settings the wrong dependencies From 1a06b7db316d48f3b1dfe8cc13366014ec3e61b3 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 13:46:14 +0100 Subject: [PATCH 14/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 1c704ad2..0ccfabe8 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -82,7 +82,7 @@ The way this would work in pseudo-code is this way: 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 say (as sub-hooks would not always be called), as the isolated scope would behave like a sub-component. +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. From 56c5e7e4f01cf8644c47d7382c5bf3bdbc15c0e7 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Wed, 13 Dec 2023 14:04:57 +0100 Subject: [PATCH 15/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 0ccfabe8..ea7735ce 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -288,7 +288,7 @@ As this is deeply related to this "component-like scope", it’s also impossible 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](#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 more finer control. +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](#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. # Alternatives From b39fffedf8b5694790dd6e06dc80eab5c81741c8 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Thu, 14 Dec 2023 00:12:05 +0100 Subject: [PATCH 16/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index ea7735ce..e63d8a82 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -1,5 +1,5 @@ - Start Date: 2023-12-13 -- RFC PR: (leave this empty) +- RFC PR: https://github.com/reactjs/rfcs/pull/257 - React Issue: (leave this empty) # Summary From 71f3abcaca3de16a1e04046fd26cf8576ea23b33 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Mon, 18 Dec 2023 11:05:13 +0100 Subject: [PATCH 17/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index e63d8a82..9d501afb 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -194,11 +194,11 @@ const useLongPoll = (url, delay) => { const [id, setId] = React.useState(0); React.useEffect(() => { - const intervalId = setInterval(() => setId(id => id +1), delay); + const intervalId = setInterval(() => setId(id => id + 1), delay); return () => clearInterval(intervalId); }, []); - const [status, setStatus] React.useState(); + const [status, setStatus] = React.useState(); React.useEffect(() => { const controller = new AbortController(); fetchStatus(url, { signal: controller.signal }).then(result => setStatus(result)); From 9e6bb7b049e33a68201c74d5494f3ab80d3d1804 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Tue, 19 Dec 2023 00:53:38 +0100 Subject: [PATCH 18/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 9d501afb..3ee6c543 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -168,18 +168,17 @@ const MyComponent = (props) => { const [otherState] = React.useState({}); const [arr, setArr] = React.useState([]); - - const foo = React.useIsolation(() => { - return React.useContext(CustomHeavyContext).foo; - }, []); // Here the dependencies could have been fully avoided, as `React.useContext(CustomHeavyContext).foo` is by definition stable - const concatArr = React.useMemo(() => { - return [...foo, ...arr, ...props.otherArr]; - }, [foo, arr, props.otherArr]); + 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]); }; ``` -Here we need to use `useMemo` and as computing `[...React.useContext(CustomHeavyContext).foo, ...arr, ...props.otherArr]` would re-generate a new array every time, even if `React.useContext(CustomHeavyContext).foo` doesn’t change. +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. From eb5f5b8c28916b3384a89ac4b3fce2e7e57ef3ca Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Tue, 19 Dec 2023 00:54:08 +0100 Subject: [PATCH 19/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 3ee6c543..3f9b0877 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -159,7 +159,7 @@ In this example: `concatString` will only be recomputed if: 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. -
Similar example with a non-stable value +### Similar example with a non-stable value ```jsx const CustomHeavyContext = React.createContext({ foo: [], bar: {}, paz: new Map() }); @@ -180,8 +180,6 @@ const MyComponent = (props) => { 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. -
- ## Wrapping existing hooks for perf optimizations only 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). From 9ea6db33925ca5fda6dbad0b8fe21565ed119304 Mon Sep 17 00:00:00 2001 From: Benjamin Koltes Date: Tue, 19 Dec 2023 00:56:54 +0100 Subject: [PATCH 20/20] Update 0000-use-isolation.md --- text/0000-use-isolation.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/text/0000-use-isolation.md b/text/0000-use-isolation.md index 3f9b0877..2f353020 100644 --- a/text/0000-use-isolation.md +++ b/text/0000-use-isolation.md @@ -172,9 +172,12 @@ const MyComponent = (props) => { const concatArr = React.useIsolation(() => { const context = React.useContext(CustomHeavyContext); return React.useMemo(() => { - return [...context.foo, ...arr, ...props.otherArr] + 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 }; ```