From da9c15ccca5016654e824fba2b81f7b7e3d936d4 Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Thu, 12 Dec 2024 12:24:28 +0100 Subject: [PATCH 1/8] WIP, testing formatting --- text/0000-use-for-each.md | 397 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 text/0000-use-for-each.md diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md new file mode 100644 index 00000000..ace2cdc7 --- /dev/null +++ b/text/0000-use-for-each.md @@ -0,0 +1,397 @@ +- Start Date: 2024-12-10 +- RFC PR: (leave this empty) +- React Issue: (leave this empty) + +# Summary + +The `useForEach()` hook provides a sane mechanism for calling React Hooks inside loops. + +# Basic example + +The `useForEach(keys, callback)` hook calls `callback` once for each + +```ts +import { useForEach } from "react"; + +const results = useForEach(keys, (key) => { + const [conn, setConn] = useState(null); +}); +``` + +# Motivation + +React hooks deal with a single piece of data. + +- `useEffect` synchronizes a single external resource with React. +- `useMemo` and `useCallback` memoize a single value. + +--- + +Two ubiquitous concepts we encounter in almost every React application are: [lifting state up](https://react.dev/learn/sharing-state-between-components), and [synchronizing with effects](https://react.dev/learn/synchronizing-with-effects). +But using both concepts _at the same time_ can be surprisingly difficult. + +## ChatRoom example + +This code snippet is taken from the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) chapter, slightly altered with a `useState()` hook to give the component access to the connection object. + +```tsx +function ChatRoom({ roomId }) { + const connection = useSingleConnection(roomId); + // ... +} + +function useSingleConnection(roomId) { + const [conn, setConn] = useState(null); + useEffect(() => { + const connection = createConnection(roomId); + setConn(connection); + return () => { + connection.disconnect(); + setConn(null); + }; + }, [roomId]); + return conn; +} +``` + +Now, imagine we want to extend our chat app, and support connecting to multiple chat rooms at the same time. +We want to render a [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) component where each chat room is displayed with a tab. + +```tsx +function ChatApp({ rooms }) { + const [activeTab, setActiveTab] = useState(0); + return ( + room.name)} + activeTab={activeTab} + onTabChange={(i) => setActiveTab(i)} + > + + + ); +} +``` + +Finally, if there are unread messages in a chatroom, we want to show a badge on that tab. +Assuming we can read this information from the connection object, then we need to lift the state up – the connections must be moved from the `ChatRoom` to the `ChatApp`. + +Ideally, we could just use a loop in `ChatApp` to iterate over `rooms`, like this: + +```tsx +function ChatApp({ rooms }) { + const connections = rooms.map((room) => useSingleConnection(room.id)); + // ... +} +``` + +However, this is forbidden by the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). +Instead, we have to move the loop inside the effect. + +```tsx +function useMultipleConnections(roomIds) { + const [conns, setConns] = useState([]); + useEffect(() => { + const connections = roomIds.map((roomId) => createConnection(roomId)); + setConns(connections); + return () => { + connections.forEach((connection) => connection.disconnect()); + setConns([]); + }; + }, [roomIds]); + return conns; +} +``` + +But this is still not perfect. +Whenever the user connects to a new chatroom, or even just reorders their rooms list, this code closes and re-opens all connections. +This can cause flashing UI elements, and child components possibly losing state or re-triggering their own effects. + +To work around this issue, we either need to use some non-idiomatic React Ref trickery; or move the connection management out of React and into our own state management solution. +In any case, we lose the clean React semantics and lifecycle guarantees that `useEffect` provides. + +## With `useForEach()` + +Idiomatic React is all about _composition_. +Ideally, we want to compose the `useMultipleConnections()` hook from the existing `useSingleConnection()` hook. +The `useForEach()` hook is one possible API design to achieve this goal. + +```tsx +function ChatApp({ rooms }) { + const roomIds = rooms.map((room) => room.id); + const connections = useForEach(roomIds, (roomId) => + useSingleConnection(room.id) + ); + // ... +} +``` + +The hook can effectively be used to convert any hook that manages a single state, effect or resource, into a hook that manages an array of said state, effects or resources. + +# Detailed design + +## Reference + +```tsx +import { type Key } from "react"; + +declare function useForEach( + keys: readonly K[], + callback: (key: K) => T +): readonly T[]; +``` + +Call `useForEach` at the top level of your component (or inside another `useForEach` callback) to loop through an array, and call Hooks inside the loop body. + +### Parameters + +- `keys`: The array controlling the +- `callback`: + +### Returns + +A frozen array containing the results from calling `callback` with all `keys`. + +--- + +The `useForEach()` hook is called with two parameters, both mandatory: + +1. The `keys` parameter is an array of unique strings and/or numbers. +2. The `callback` parameter is a function that accepts a single string or number, and produces an arbitrary value. + +The `useForEach()` hook synchronously calls `callback` with each value in `keys`, and returns an array of all the callback return values. +The `keys` array may change over time, including the length, order, and values. +The `callback` function may call hooks, following the normal rules of hooks. +This + +https://react.dev/learn/rendering-lists#rules-of-keys + +## The `keys` array + +The first parameter of the `useForEach()` hook is an array of keys. +These keys serve the same purpose as they do in JSX arrays: +React uses the keys to track an "instance" of `callback` over the lifetime of the containing component. + +The `keys` array may change over time, including the length, order, and values. + +## The `callback` function + +The second parameter of the `useForEach()` hook is a callback function. + +The `keys` array must not contain duplicates, as determined by `Object.is()`. +If any duplicates are found, `useForEach()` issues a console warning in development mode, in the same fashion as React warning about duplicate keys in JSX elements. + +## Corner cases + +- The `useForEach` hook does not catch errors. + When `callback` throws an error, it will bubble up and terminate the current render. + This follows the example established by the `useMemo` callback and `useState` initializer callback. + +- Passing duplicate values inside the `keys` array triggers a [duplicate keys](https://github.com/facebook/react/blob/a4964987dc140526702e996223fe7ee293def8ac/packages/react-reconciler/src/ReactChildFiber.js#L1070-L1077) error. + There are two possibilities how this error could be reported: + + 1. The error is logged to `console.error` in development, and silently discarded in production. + React tries to match loop iterations to hook state via the array index. + If the `keys` array changes in a subsequent render and an array element cannot be matched to its previous, the loop "instances" of the duplicate keys can become "orphaned". + This follows the example established by JSX keys. + Appendix "React handling of duplicate keys in JSX" demonstrates this behaviour for JSX elements. + 2. The error is thrown, terminating the current render. + There is no precedence in React for throwing an error on duplicate keys. + + While it is generally + +- To determine key equality, the `useForEach` hook internally converts all elements in the `keys` array to strings. + All of the following arrays will trigger a duplicate keys error: + `["1", 1]`, `[{}, {}]`, `["null", null]` + This follows the example established by JSX keys. + +This is the bulk of the RFC. Explain the design in enough detail for somebody +familiar with React to understand, and for somebody familiar with the +implementation to implement. This should get into specifics and corner-cases, +and include examples of how the feature is used. Any new terminology should be +defined here. + +## Sharing state between iterations + +```tsx +function useMultipleConnections(roomIds) { + const [connections, setConnections] = useState({}); + useForEach(roomIds, (roomId) => { + useEffect(() => { + const connection = createConnection(roomId); + setConnections((prev) => ({ ...prev, [roomId]: connection })); + return () => { + connection.disconnect(); + setConn((prev) => { + const { [roomId]: _, ...rest } = { ...prev }; + return rest; + }); + }; + }, [roomId]); + }); + return connections; +} +``` + +# Drawbacks + +Why should we _not_ do this? Please consider: + +- implementation cost, both in term of code size and complexity +- whether the proposed feature can be implemented in user space +- the impact on teaching people React +- integration of this feature with other existing and planned features +- cost of migrating existing React applications (is it a breaking change?) + +There are tradeoffs to choosing any path. Attempt to identify them here. + +# Alternatives + +Handle collections of state and/or effects outside of React, then synchronize them with React via `useEffect` or `useSyncExternalStore`. + +What other designs have been considered? What is the impact of not doing this? + +# Adoption strategy + +If we implement this proposal, how will existing React developers adopt it? Is +this a breaking change? Can we write a codemod? Should we coordinate with +other projects or libraries? + +The `uesForEach()` hook allows to + +# How we teach this + +The `useForEach()` hook is a continuation of two established concepts in React: keys and hook composition. + +It is an advanced concept, and fits well as its own sub-page in the [Escape Hatches](https://react.dev/learn/escape-hatches) chapter. +, lifting state up, and synchronizing with effects. + +What names and terminology work best for these concepts and why? How is this +idea best presented? As a continuation of existing React patterns? + +Would the acceptance of this proposal mean the React documentation must be +re-organized or altered? Does it change how React is taught to new developers +at any level? + +How should this feature be taught to existing React developers? + +# Unresolved questions + +Optional, but suggested for first drafts. What parts of the design are still +TBD? + +Alternative names: + +- `useLoop()`, `useRepeat()` +- `useMap()` +- `useNestedHooks()` + +# Appendices + +All code listings were tested with React v19.0. + +## Appendix A: React handling of duplicate keys in JSX + +This appendix demonstrates how React handles duplicate keys in JSX arrays. +Before we look at the duplicate keys case, let's first examine a well-behaving program. + +https://github.com/user-attachments/assets/eef30d1f-f54b-4eee-96c1-c239f258aaea + +
+Listing 10-1: A well-behaving React app with unique keys. + +```tsx +import { useEffect, useState } from "react"; + +const effectsRun = { Z: 0, A: 0, B: 0, C: 0 }; +const cleanupsRun = { Z: 0, A: 0, B: 0, C: 0 }; + +export function App() { + const [hidden, setHidden] = useState(false); + const [n, setN] = useState(0); + useEffect(() => { + console.table({ effectsRun, cleanupsRun }); + }); + + return ( + <> + {" "} + + {!hidden && + [ + , + , + , + ].toSpliced(n, 0, )} + + ); +} + +function Child({ value }) { + useEffect(() => { + effectsRun[value]++; + return () => { + cleanupsRun[value]++; + }; + }); + return
{value}
; +} +``` + +
+ +Listing 10-1 demonstrates how React operates under normal circumstances. + +```diff +export function App() { + const [hidden, setHidden] = useState(false); + const [n, setN] = useState(0); + useEffect(() => { + console.table({ effectsRun, cleanupsRun }); + }); + + return ( + <> + {" "} + + {!hidden && + [ + , +- , +- , ++ , ++ , + ].toSpliced(n, 0, )} + + ); +} +``` + +_Listing 10-2:_ + +This code renders four characters `z`, `a`, `b` and `c`, together with a number indicating when each character last got rendered. +Pressing the button moves the `z` character to the next position, wrapping around at the end. +When clicking the button five times, the expected order of characters is: + +- `z1, a1, b1, c1` +- `a2, z2, b2, c2` +- `a3, b3, z3, c3` +- `a4, b4, c4, z4` +- `z5, a5, b5, c5` + +However, because the characters `a`, `b`, `c` all have the same key, React is unable to track them across multiple renders. +This results in the following output after five clicks (and renders): + +- `z1`, `a1`, `b1`, `c1` +- `a1`, `b1`, `a2`, `z2`, `b2`, `c2` +- `a1`, `b1`, `a3`, `b2`, `b3`, `z3`, `c3` +- `a1`, `b1`, `a4`, `b2`, `b4`, `c4`, `z4` +- `a1`, `b1`, `a4`, `b2`, `b4`, `z5`, `a5`, `b5`, `c5` + +We can observe how React loses track of elements with duplicate keys. +Instead of updating the existing elements, some elements become orphaned, never receiving updates again. +To keep the total number of rendered elements at 4, React creates totally new elements instead. +Over time, orphaned elements pile up in the DOM. From 6527fef28c6da84db9a005e997fda9eb29c80e05 Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Thu, 12 Dec 2024 12:51:42 +0100 Subject: [PATCH 2/8] extend appendix a --- text/0000-use-for-each.md | 79 +++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index ace2cdc7..63fb2e8e 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -287,7 +287,7 @@ Alternative names: # Appendices -All code listings were tested with React v19.0. +All code listings were tested with React v19.0 in production mode, inside ``. ## Appendix A: React handling of duplicate keys in JSX @@ -306,7 +306,7 @@ const effectsRun = { Z: 0, A: 0, B: 0, C: 0 }; const cleanupsRun = { Z: 0, A: 0, B: 0, C: 0 }; export function App() { - const [hidden, setHidden] = useState(false); + const [hidden, setHidden] = useState(true); const [n, setN] = useState(0); useEffect(() => { console.table({ effectsRun, cleanupsRun }); @@ -341,11 +341,39 @@ function Child({ value }) { -Listing 10-1 demonstrates how React operates under normal circumstances. +The "show/hide" button mounts/unmounts a list of four children `A`, `B`, `C`, `Z`. +The "Z >> 1" button moves the `Z` child to the next position in the list, wrapping around after the last position. + +The children run an effect after every render, and a cleanup before their next render (or unmount). +The devtools table displays how often each effect and cleanup function is run. +We can see that effects run once per render, and all effects are cleaned up again. + +To observe how React handles duplicate keys, we give the children `A`, `B` and `C` the same key. ```diff + {!hidden && + [ + , +- , +- , ++ , ++ , + ].toSpliced(n, 0, )} +``` + +https://github.com/user-attachments/assets/7636308c-ae68-4d87-b16f-326904b79a65 + +
+Listing 10-2: A React app with glitches due to duplicate keys. + +```tsx +import { useEffect, useState } from "react"; + +const effectsRun = { Z: 0, A: 0, B: 0, C: 0 }; +const cleanupsRun = { Z: 0, A: 0, B: 0, C: 0 }; + export function App() { - const [hidden, setHidden] = useState(false); + const [hidden, setHidden] = useState(true); const [n, setN] = useState(0); useEffect(() => { console.table({ effectsRun, cleanupsRun }); @@ -360,38 +388,25 @@ export function App() { {!hidden && [ , -- , -- , -+ , -+ , + , + , ].toSpliced(n, 0, )} ); } -``` - -_Listing 10-2:_ - -This code renders four characters `z`, `a`, `b` and `c`, together with a number indicating when each character last got rendered. -Pressing the button moves the `z` character to the next position, wrapping around at the end. -When clicking the button five times, the expected order of characters is: -- `z1, a1, b1, c1` -- `a2, z2, b2, c2` -- `a3, b3, z3, c3` -- `a4, b4, c4, z4` -- `z5, a5, b5, c5` - -However, because the characters `a`, `b`, `c` all have the same key, React is unable to track them across multiple renders. -This results in the following output after five clicks (and renders): +function Child({ value }) { + useEffect(() => { + effectsRun[value]++; + return () => { + cleanupsRun[value]++; + }; + }); + return
{value}
; +} +``` -- `z1`, `a1`, `b1`, `c1` -- `a1`, `b1`, `a2`, `z2`, `b2`, `c2` -- `a1`, `b1`, `a3`, `b2`, `b3`, `z3`, `c3` -- `a1`, `b1`, `a4`, `b2`, `b4`, `c4`, `z4` -- `a1`, `b1`, `a4`, `b2`, `b4`, `z5`, `a5`, `b5`, `c5` +
-We can observe how React loses track of elements with duplicate keys. -Instead of updating the existing elements, some elements become orphaned, never receiving updates again. -To keep the total number of rendered elements at 4, React creates totally new elements instead. -Over time, orphaned elements pile up in the DOM. +We can see that React doesn't properly unmount all elements, and also doesn't run all effect cleanup callbacks. +In a real application, this can lead to a UI glitches with duplicated UI elements, and possibly memory leaks due to external resources that are allocated but never cleaned up. From 08f670950d105bf263f4e334608f50e4eee34717 Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Tue, 17 Dec 2024 18:17:31 +0100 Subject: [PATCH 3/8] update "Detailed Design" section --- text/0000-use-for-each.md | 262 ++++++++++++++++++++++++++++---------- 1 file changed, 197 insertions(+), 65 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 63fb2e8e..0d098b81 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -4,33 +4,52 @@ # Summary -The `useForEach()` hook provides a sane mechanism for calling React Hooks inside loops. +The `useForEach` hook provides a sane mechanism for calling React Hooks inside loops. # Basic example -The `useForEach(keys, callback)` hook calls `callback` once for each +The `useForEach(keys, callback)` hook calls `callback` once for each element in the `keys` iterable. +The `callback` function is allowed to call hooks, as if it was at the top level of the component. ```ts -import { useForEach } from "react"; +import { useEffect, useForEach, useMemo, useState } from "react"; const results = useForEach(keys, (key) => { - const [conn, setConn] = useState(null); + const [state, setState] = useState(/* ... */); + useEffect(/* ... */); + return useMemo(/* ... */); }); ``` # Motivation -React hooks deal with a single piece of data. +Once you have learned to think in React, synchronizing a single external system with React is simple, sane and straight-forward. +The `useSyncExternalStore` and `useEffect` hooks let you subscribe to a single external state, or allocate and clean-up a single resource, respectively. +The hooks have clear semantics and a predictable lifecycle +predictable, easy to reason about, colocating allocation and cleanup, + +`useEffect` is an excellent/genius API because predictable, colocating. We want these attributes also for managing array of external resources, but the hook doesn't work that way + +React provides several hooks that deal with a single piece of data. - `useEffect` synchronizes a single external resource with React. - `useMemo` and `useCallback` memoize a single value. +- `useId` allocates a single document-wide unique id. + +But when an applications has to deal with an array of such data, React doesn't offer a canonical solution. --- -Two ubiquitous concepts we encounter in almost every React application are: [lifting state up](https://react.dev/learn/sharing-state-between-components), and [synchronizing with effects](https://react.dev/learn/synchronizing-with-effects). -But using both concepts _at the same time_ can be surprisingly difficult. +Two ubiquitous refactoring tasks we encounter in almost every React application are: [lifting state up](https://react.dev/learn/sharing-state-between-components), and [synchronizing with effects](https://react.dev/learn/synchronizing-with-effects). +But applying both refactorings _at the same time_ (i.e. lifting effects up) can be surprisingly difficult. + +## ChatRooms example + +We can observe these problems when trying to build a -## ChatRoom example +image + +Integrating a single chat room connection with a React application is pretty straight-forward, and is covered in great detail in the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) chapter. This code snippet is taken from the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) chapter, slightly altered with a `useState()` hook to give the component access to the connection object. @@ -135,80 +154,174 @@ The hook can effectively be used to convert any hook that manages a single state import { type Key } from "react"; declare function useForEach( - keys: readonly K[], + keys: Iterable, callback: (key: K) => T ): readonly T[]; ``` -Call `useForEach` at the top level of your component (or inside another `useForEach` callback) to loop through an array, and call Hooks inside the loop body. - ### Parameters -- `keys`: The array controlling the -- `callback`: +- `keys`: The iterable on which the loop operates. + It should contain only strings and/or numbers, and should not contain duplicates. + + The iterable should be a dynamic value and come from e.g. props or another hook call. + This is not a dependency array like for the `useMemo` or `useEffect` hooks, and should not be an array literal. + +- `callback`: The function that is executed for each element in `keys`. + It should be pure, should take a single `key` argument, and may return a value of any type. + It may call other React Hooks. + + Hooks that are called inside `callback` use the passed-in `key` to track their state across multiple renders. + For example, a `useState` hook will always return the state for the same key, even if that key moves to different indexes in the `keys` iterable over multiple renders. + Likewise, a `useEffect` hook will compare the current dependencies with the previous dependencies of the same key to determine whether to execute again. + + If `keys` contains a new key that wasn't present in the previous render, then the Hooks for that key will be newly initialized, like it normally happens during the first render of a component. + For example, `useMemo` will call its `calculateValue` callback, because there are no previous dependencies to compare yet. + + If `keys` doesn't contain a key that was present in the previous render, then the Hooks associated with that key are "unmounted". + Effect hooks like `useEffect` and `useSyncExternalStore` execute their cleanup; stateful hooks like `useState`, `useMemo` and `useRef` drop all references to their values. + When that same key appears again in a subsequent render, then it gets newly initialized again. ### Returns A frozen array containing the results from calling `callback` with all `keys`. ---- +The order of values inside the results array matches the order of `keys`. +For example, if `keys` is `[1, 2, 3]` during one render and `[2, 1, 3]` during the next, then the first results array will be `[callback(1), callback(2), callback(3)]`, and the second will be `[callback(2), callback(1), callback(3)]`. -The `useForEach()` hook is called with two parameters, both mandatory: +React will return the same array during consecutive renders, if the number of keys hasn't changed, each index in the results array contains the same value as during the previous render (as determined by `Object.is`). +To prevent inadvertent mutations that would leak into consecutive renders, the results array is [frozen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze). -1. The `keys` parameter is an array of unique strings and/or numbers. -2. The `callback` parameter is a function that accepts a single string or number, and produces an arbitrary value. +## Usage -The `useForEach()` hook synchronously calls `callback` with each value in `keys`, and returns an array of all the callback return values. -The `keys` array may change over time, including the length, order, and values. -The `callback` function may call hooks, following the normal rules of hooks. -This +### Executing an effect for each element in an array -https://react.dev/learn/rendering-lists#rules-of-keys +To execute an effect for each element in an array, wrap the `useEffect` call with `useForEach`. -## The `keys` array +```tsx +function ChatApp({ roomIds }) { + useForEach(roomIds, (roomId) => { + useEffect(() => { + const connection = createConnection(roomId); + return () => { + connection.close(); + }; + }, [roomId]); + }); +} +``` -The first parameter of the `useForEach()` hook is an array of keys. -These keys serve the same purpose as they do in JSX arrays: -React uses the keys to track an "instance" of `callback` over the lifetime of the containing component. +You can think of this code as being equivalent to this: -The `keys` array may change over time, including the length, order, and values. +```tsx +function ChatApp({ roomIds }) { + for (const roomId of roomIds) { + useEffect(() => { + const connection = createConnection(roomId); + return () => { + connection.close(); + }; + }, [roomId]); + } +} +``` -## The `callback` function +The second code listing (with the `for ... of` loop) is not valid React code, because it violates the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). +The first code listing (with the `useForEach` hook) is valid React code, follows the Rules of Hooks, and achieves the same goal. + +### Associating state with keys + +If you need to store some state for each of your keys, you have two options. + +1. You can store the state for each key inside a separate state hook, like this: + + ```tsx + function ChatApp({ roomIds }) { + const connections = useForEach(roomIds, (roomId) => { + const connections = useEffect(() => { + const [conn, setConn] = useState(null); // <- State variable that stores a single connection object. + const connection = createConnection(roomId); + setConn(connection); // <- Write to the local state variable. + return () => { + connection.close(); + setConn(null); + }; + }, [roomId]); + return conn; // <- Pass the local variable to the parent scope. + }); + } + ``` + + Call `useState` inside the `useForEach` callback to create a local state variable. + Write to that state within the effect, and return the state from the callback function. + `useForEach` returns an array of all the callback results. + + To find the connection corresponding to a specific key, check the results array at that same index: + + ```tsx + for (let i = 0; i < roomIds.length; i++) { + const roomId = roomIds[i]; + const connection = connections[i]; + } + ``` + +2. Alternatively, you can store all values in a single state variable, inside a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or object. + + ```tsx + function ChatApp({ roomIds }) { + const [connections, setConnections] = useState({}); // <- State variable that stores all connections. + useForEach(roomIds, (roomId) => { + const connections = useEffect(() => { + const connection = createConnection(roomId); + setConnections((prev) => ({ ...prev, [roomId]: connection })); // <- Write to the component-level state. + return () => { + connection.close(); + setConnections(([roomId]: _, ...rest) => rest); + }; + }, [roomId]); + // No `return` needed here. + }); + } + ``` + + To find the connection corresponding to a specific key, index the map by that key: + + ```tsx + for (const roomId of roomIds) { + const connection = connections[roomId]; + } + ``` + +Option 1 gives you an array of values, where the element order is guaranteed to match the `keys` order of the current render. +Option 2 gives you a map from key to value, but the iteration order of the map may get out of sync with the `keys` iteration order over time. +Which option is better depends on your use case. -The second parameter of the `useForEach()` hook is a callback function. +## Corner cases -The `keys` array must not contain duplicates, as determined by `Object.is()`. -If any duplicates are found, `useForEach()` issues a console warning in development mode, in the same fashion as React warning about duplicate keys in JSX elements. +### Exception handling -## Corner cases +The `useForEach` hook does not catch errors. +When `callback` throws an error, it will bubble up and terminate the current render. +This follows the example established by the `useMemo` callback and `useState` initializer callback. -- The `useForEach` hook does not catch errors. - When `callback` throws an error, it will bubble up and terminate the current render. - This follows the example established by the `useMemo` callback and `useState` initializer callback. +### Duplicate keys -- Passing duplicate values inside the `keys` array triggers a [duplicate keys](https://github.com/facebook/react/blob/a4964987dc140526702e996223fe7ee293def8ac/packages/react-reconciler/src/ReactChildFiber.js#L1070-L1077) error. - There are two possibilities how this error could be reported: +Passing a `keys` iterable that contains duplicate values triggers a [duplicate keys](https://github.com/facebook/react/blob/a4964987dc140526702e996223fe7ee293def8ac/packages/react-reconciler/src/ReactChildFiber.js#L1070-L1077) error. +The error is logged to `console.error` in development, and silently discarded in production. - 1. The error is logged to `console.error` in development, and silently discarded in production. - React tries to match loop iterations to hook state via the array index. - If the `keys` array changes in a subsequent render and an array element cannot be matched to its previous, the loop "instances" of the duplicate keys can become "orphaned". - This follows the example established by JSX keys. - Appendix "React handling of duplicate keys in JSX" demonstrates this behaviour for JSX elements. - 2. The error is thrown, terminating the current render. - There is no precedence in React for throwing an error on duplicate keys. +For duplicate keys, React tries to match loop `callback` calls to hook state via the iteration index. +If matching based on the index fails, the loop "instances" of the duplicate keys and the associated Hooks become "orphaned". +Orphaned state hooks can be garbage collected because they can never be read again, and orphaned effect hooks will never execute their cleanup function. +See [Appendix A: React handling of duplicate keys in JSX](#appendix-a-react-handling-of-duplicate-keys-in-jsx) for a demonstration of this behaviour for JSX elements. - While it is generally +This follows the example established by JSX keys. -- To determine key equality, the `useForEach` hook internally converts all elements in the `keys` array to strings. - All of the following arrays will trigger a duplicate keys error: - `["1", 1]`, `[{}, {}]`, `["null", null]` - This follows the example established by JSX keys. +### Key type coercion -This is the bulk of the RFC. Explain the design in enough detail for somebody -familiar with React to understand, and for somebody familiar with the -implementation to implement. This should get into specifics and corner-cases, -and include examples of how the feature is used. Any new terminology should be -defined here. +To determine key equality, the `useForEach` hook internally converts all elements in the `keys` array to strings. +All of the following arrays will trigger a duplicate keys error: +`["1", 1]`, `[{}, {}]`, `["null", null]` +This follows the example established by JSX keys. ## Sharing state between iterations @@ -234,6 +347,15 @@ function useMultipleConnections(roomIds) { # Drawbacks +Foot gun - this hook is powerful, and as such requires a certain level of care and understanding to use properly. +Improper use can cause problems like: + +- performance issues due to excessive hook calls +- memory leaks + +The issue is even greater here than with JSX arrays because this hook will probably be used almost exclusively for effects. +Maybe this complexity should not be made more accessible, and should be left to experienced engineers who build solutions outside of React. + Why should we _not_ do this? Please consider: - implementation cost, both in term of code size and complexity @@ -276,26 +398,30 @@ How should this feature be taught to existing React developers? # Unresolved questions -Optional, but suggested for first drafts. What parts of the design are still -TBD? - -Alternative names: - -- `useLoop()`, `useRepeat()` -- `useMap()` -- `useNestedHooks()` +- Implementation cost, both in term of code size and complexity. +- Is this hook compatible/composable with all other hooks? + I have never used `useActionState`, `useDeferredValue`, `useOptimistic`, and `useTransition`. +- What is a good name for this hook? + List of ideas: + - `useForEach` (from [`Array.forEach`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach)) + - `useMap` (from [`Array.map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)) + - `useLoop`, `useRepeat` + - `useNestedHooks` # Appendices All code listings were tested with React v19.0 in production mode, inside ``. +You can run the code locally, [here](https://github.com/pschiffmann/use-for-each-playground) is the repository. ## Appendix A: React handling of duplicate keys in JSX This appendix demonstrates how React handles duplicate keys in JSX arrays. -Before we look at the duplicate keys case, let's first examine a well-behaving program. +Before we look at the duplicate keys case, we first examine a well-behaving program. https://github.com/user-attachments/assets/eef30d1f-f54b-4eee-96c1-c239f258aaea +[live demo](https://pschiffmann.github.io/use-for-each-playground/appendix-a-unique-keys.html) +
Listing 10-1: A well-behaving React app with unique keys. @@ -363,8 +489,10 @@ To observe how React handles duplicate keys, we give the children `A`, `B` and ` https://github.com/user-attachments/assets/7636308c-ae68-4d87-b16f-326904b79a65 +[live demo](https://pschiffmann.github.io/use-for-each-playground/appendix-a-duplicate-keys.html) +
-Listing 10-2: A React app with glitches due to duplicate keys. +Listing 10-2: A React app with glitches caused by duplicate keys. ```tsx import { useEffect, useState } from "react"; @@ -409,4 +537,8 @@ function Child({ value }) {
We can see that React doesn't properly unmount all elements, and also doesn't run all effect cleanup callbacks. -In a real application, this can lead to a UI glitches with duplicated UI elements, and possibly memory leaks due to external resources that are allocated but never cleaned up. +In a real application, this can lead to UI glitches with duplicated UI elements, and possibly memory leaks due to external resources that are allocated but never cleaned up. + +## Appendix B: Managing an array of external resources with `useEffect` + +## Appendix C: `useSyncExternalStore` to access an array of external states From 3e6059cc9507606480471c29884a03d8f05096d3 Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Tue, 17 Dec 2024 21:47:30 +0100 Subject: [PATCH 4/8] update "Motivation" section --- text/0000-use-for-each.md | 203 +++++++++++++++++++++++++------------- 1 file changed, 136 insertions(+), 67 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 0d098b81..625a4699 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -14,46 +14,61 @@ The `callback` function is allowed to call hooks, as if it was at the top level ```ts import { useEffect, useForEach, useMemo, useState } from "react"; -const results = useForEach(keys, (key) => { - const [state, setState] = useState(/* ... */); - useEffect(/* ... */); - return useMemo(/* ... */); -}); +function MyComponent() { + const results = useForEach(keys, (key) => { + const [state, setState] = useState(/* ... */); + useEffect(/* ... */); + return useMemo(/* ... */); + }); + // ... +} ``` # Motivation -Once you have learned to think in React, synchronizing a single external system with React is simple, sane and straight-forward. -The `useSyncExternalStore` and `useEffect` hooks let you subscribe to a single external state, or allocate and clean-up a single resource, respectively. -The hooks have clear semantics and a predictable lifecycle -predictable, easy to reason about, colocating allocation and cleanup, - -`useEffect` is an excellent/genius API because predictable, colocating. We want these attributes also for managing array of external resources, but the hook doesn't work that way - -React provides several hooks that deal with a single piece of data. +Once you have learned to think in React, synchronizing a _single_ external system with React is straight-forward, sane, and predictable: +Connect to the system inside an effect, disconnect from the system inside the cleanup of that same effect. +React guarantees that effects and cleanups are executed in a well-defined, predictable order, which makes it relatively easy to reason about race conditions and memory leaks. -- `useEffect` synchronizes a single external resource with React. -- `useMemo` and `useCallback` memoize a single value. -- `useId` allocates a single document-wide unique id. +Unfortunately, this mental model cannot be applied today if we need to synchronize _a dynamic number_ of external systems. +The natural way to process multiple values is to iterate over them, but loops and Hooks don't compose: -But when an applications has to deal with an array of such data, React doesn't offer a canonical solution. +1. Placing the `useEffect` call inside a `for ... of` loop is forbidden by the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). +2. Placing the `for ... of` loop inside the `useEffect` call will execute the cleanup function for _all_ elements whenever _any_ element changes. ---- - -Two ubiquitous refactoring tasks we encounter in almost every React application are: [lifting state up](https://react.dev/learn/sharing-state-between-components), and [synchronizing with effects](https://react.dev/learn/synchronizing-with-effects). -But applying both refactorings _at the same time_ (i.e. lifting effects up) can be surprisingly difficult. +Today, applications that need to connect to a dynamic number of external systems have no other choice than to use non-idiomatic workarounds. +This increases the risk of race conditions and memory leaks, makes the code harder to read, and causes code duplication if both single-connection and multi-connection hooks are needed for the same external system. ## ChatRooms example -We can observe these problems when trying to build a +To give a specific example, we will look at a simple chat app. +The app allows users to connect to multiple chat rooms at the same time. +The UI renders one chat room at a time, and users can switch between all connected chat rooms via a [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) component. +A badge over each tab tells the user if that chat room has unread messages. -image +image -Integrating a single chat room connection with a React application is pretty straight-forward, and is covered in great detail in the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) chapter. +[live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) -This code snippet is taken from the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) chapter, slightly altered with a `useState()` hook to give the component access to the connection object. +Connecting to a single chat room is pretty straight-forward, and is covered in great detail in the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) docs. +We can use the `ChatRoom` component from the docs as a starting point, and render one `ChatRoom` per tab. ```tsx +function ChatApp() { + const [roomIds, setRoomIds] = useState(/* ... */); + + return ( + ({ + key: roomId, + label: roomId, + badge: 0, // ??? + content: , + }))} + /> + ); +} + function ChatRoom({ roomId }) { const connection = useSingleConnection(roomId); // ... @@ -62,7 +77,7 @@ function ChatRoom({ roomId }) { function useSingleConnection(roomId) { const [conn, setConn] = useState(null); useEffect(() => { - const connection = createConnection(roomId); + const connection = new ChatRoomConnection(roomId); setConn(connection); return () => { connection.disconnect(); @@ -73,78 +88,132 @@ function useSingleConnection(roomId) { } ``` -Now, imagine we want to extend our chat app, and support connecting to multiple chat rooms at the same time. -We want to render a [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) component where each chat room is displayed with a tab. +While this implementation is a decent start, it has two limitations. +First, connections are closed and re-opened whenever we switch tabs (because the `Tabs` component unmounts hidden tabs). +Second, we can't render the "unread messages" badge count because the `ChatApp` component doesn't have access to the connection objects. + +To address both issues, we need to lift the connection state up into `ChatApp`. +Ideally, we could reuse the `useSingleConnection` Hook. ```tsx -function ChatApp({ rooms }) { - const [activeTab, setActiveTab] = useState(0); +function ChatApp() { + const [roomIds, setRoomIds] = useState(/* ... */); + const connections = useMultipleConnections(roomIds); + const unreadCounts = useUnreadCounts(connections); + return ( room.name)} - activeTab={activeTab} - onTabChange={(i) => setActiveTab(i)} - > - - + tabs={roomIds.map((roomId, i) => ({ + key: roomId, + label: roomId, + badge: unreadCounts[i], + content: , + }))} + /> ); } + +function useMultipleConnections(roomIds) { + return roomIds.map((roomId) => useSingleConnection(roomId)); +} ``` -Finally, if there are unread messages in a chatroom, we want to show a badge on that tab. -Assuming we can read this information from the connection object, then we need to lift the state up – the connections must be moved from the `ChatRoom` to the `ChatApp`. +Alas, we can't. +When we connect to another chat room by adding an element to `roomIds`, React throws this error: + +> React has detected a change in the order of Hooks called by ChatApp. +> This will lead to bugs and errors if not fixed. +> For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks -Ideally, we could just use a loop in `ChatApp` to iterate over `rooms`, like this: +Instead, we have to move the `roomIds.map()` call inside the `useEffect`. ```tsx -function ChatApp({ rooms }) { - const connections = rooms.map((room) => useSingleConnection(room.id)); - // ... +function useMultipleConnections(roomIds) { + const [connections, setConnections] = useState(() => []); + useEffect(() => { + const connections = roomIds.map((roomId) => new ChatRoomConnection(roomId)); + setConnections(connections); + return () => { + connections.forEach((connection) => connection.close()); + }; + }, [roomIds]); + return connections; } ``` -However, this is forbidden by the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). -Instead, we have to move the loop inside the effect. +With the new hook implementation, we can make changes to the `roomIds` array without crashing the app. +But with every change to the array, we now close and re-open _all_ connections. +This results in flickering badges and chat content whenever the user connects to a new room, disconnects from a room, or merely moves tabs around. + +https://github.com/user-attachments/assets/556a9036-6dcf-4424-883f-04b053ab6f82 + +[live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-naive.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-naive.tsx) + +To avoid closing all connections whenever `roomIds` changes, we need to put the "connect" and "disconnect" code into different effects, with different dependencies. +We also need to use a ref object to share the connections with the "disconnect" effect. ```tsx -function useMultipleConnections(roomIds) { - const [conns, setConns] = useState([]); +function useMultipleConnectionsNonIdiomatic(roomIds) { + const connectionsRef = useRef(new Map()); + const [connections, setConnections] = useState(() => new Map()); + useEffect(() => { - const connections = roomIds.map((roomId) => createConnection(roomId)); - setConns(connections); - return () => { - connections.forEach((connection) => connection.disconnect()); - setConns([]); - }; + let hasChanges = false; + + const connections = connectionsRef.current; + // Open new connections. + for (const roomId of roomIds) { + if (connections.has(roomId)) continue; + hasChanges = true; + connections.set(roomId, new ChatRoomConnection(roomId)); + } + + // Close no longer used connections on updates. + for (const [roomId, connection] of connections) { + if (roomIds.includes(roomId)) continue; + hasChanges = true; + connections.delete(roomId); + connection.close(); + } + + if (hasChanges) setConnections(new Map(connections)); }, [roomIds]); - return conns; + + // Close all connections on unmount with a effect cleanup. + useEffect( + () => () => { + connectionsRef.current.forEach((connection) => connection.close()); + connectionsRef.current.clear(); + }, + [] + ); + + return roomIds.map((roomId) => connections.get(roomId) ?? null); } ``` -But this is still not perfect. -Whenever the user connects to a new chatroom, or even just reorders their rooms list, this code closes and re-opens all connections. -This can cause flashing UI elements, and child components possibly losing state or re-triggering their own effects. +https://github.com/user-attachments/assets/a472bc84-233c-4832-9706-b980056c552c -To work around this issue, we either need to use some non-idiomatic React Ref trickery; or move the connection management out of React and into our own state management solution. -In any case, we lose the clean React semantics and lifecycle guarantees that `useEffect` provides. +[live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) -## With `useForEach()` +This works, but it's messy. +And it only gets worse as the effect depends on more dependencies. + +### With `useForEach()` Idiomatic React is all about _composition_. Ideally, we want to compose the `useMultipleConnections()` hook from the existing `useSingleConnection()` hook. -The `useForEach()` hook is one possible API design to achieve this goal. +The `useForEach()` hook lets us do just that. ```tsx -function ChatApp({ rooms }) { - const roomIds = rooms.map((room) => room.id); - const connections = useForEach(roomIds, (roomId) => - useSingleConnection(room.id) - ); - // ... +function useMultipleConnections(roomIds) { + return useForEach(roomIds, (roomId) => { + return useSingleConnection(roomId); + }); } ``` -The hook can effectively be used to convert any hook that manages a single state, effect or resource, into a hook that manages an array of said state, effects or resources. +The hook can effectively be used to convert any hook (native or userland) that manages a single state, effect or resource, into a hook that manages an array of said state, effects or resources. # Detailed design From 88494a1457c2d4b837469fb6252402316ecfaabe Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Tue, 17 Dec 2024 21:52:15 +0100 Subject: [PATCH 5/8] typos --- text/0000-use-for-each.md | 58 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 625a4699..2e834e49 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -4,17 +4,17 @@ # Summary -The `useForEach` hook provides a sane mechanism for calling React Hooks inside loops. +The `useForEach` Hook provides a sane mechanism for calling React Hooks inside loops. # Basic example -The `useForEach(keys, callback)` hook calls `callback` once for each element in the `keys` iterable. -The `callback` function is allowed to call hooks, as if it was at the top level of the component. +The `useForEach(keys, callback)` Hook calls `callback` once for each element in the `keys` iterable. +The `callback` function is allowed to call Hooks, as if it was at the top level of the component. ```ts import { useEffect, useForEach, useMemo, useState } from "react"; -function MyComponent() { +function MyComponent({ keys }) { const results = useForEach(keys, (key) => { const [state, setState] = useState(/* ... */); useEffect(/* ... */); @@ -26,18 +26,18 @@ function MyComponent() { # Motivation -Once you have learned to think in React, synchronizing a _single_ external system with React is straight-forward, sane, and predictable: +Once you have learned to think in React, synchronizing a _single_ external system with React is straight-forward: Connect to the system inside an effect, disconnect from the system inside the cleanup of that same effect. React guarantees that effects and cleanups are executed in a well-defined, predictable order, which makes it relatively easy to reason about race conditions and memory leaks. -Unfortunately, this mental model cannot be applied today if we need to synchronize _a dynamic number_ of external systems. +Unfortunately, we can't carry over this mental model if we need to synchronize _a dynamic number_ of external systems. The natural way to process multiple values is to iterate over them, but loops and Hooks don't compose: 1. Placing the `useEffect` call inside a `for ... of` loop is forbidden by the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). 2. Placing the `for ... of` loop inside the `useEffect` call will execute the cleanup function for _all_ elements whenever _any_ element changes. Today, applications that need to connect to a dynamic number of external systems have no other choice than to use non-idiomatic workarounds. -This increases the risk of race conditions and memory leaks, makes the code harder to read, and causes code duplication if both single-connection and multi-connection hooks are needed for the same external system. +This increases the risk of race conditions and memory leaks, makes the code harder to read, and causes code duplication if both single-connection and multi-connection Hooks are needed for the same external system. ## ChatRooms example @@ -141,7 +141,7 @@ function useMultipleConnections(roomIds) { } ``` -With the new hook implementation, we can make changes to the `roomIds` array without crashing the app. +With the new Hook implementation, we can make changes to the `roomIds` array without crashing the app. But with every change to the array, we now close and re-open _all_ connections. This results in flickering badges and chat content whenever the user connects to a new room, disconnects from a room, or merely moves tabs around. @@ -202,8 +202,8 @@ And it only gets worse as the effect depends on more dependencies. ### With `useForEach()` Idiomatic React is all about _composition_. -Ideally, we want to compose the `useMultipleConnections()` hook from the existing `useSingleConnection()` hook. -The `useForEach()` hook lets us do just that. +Ideally, we want to compose the `useMultipleConnections()` Hook from the existing `useSingleConnection()` Hook. +The `useForEach()` Hook lets us do just that. ```tsx function useMultipleConnections(roomIds) { @@ -213,7 +213,7 @@ function useMultipleConnections(roomIds) { } ``` -The hook can effectively be used to convert any hook (native or userland) that manages a single state, effect or resource, into a hook that manages an array of said state, effects or resources. +The Hook can effectively be used to convert any Hook (native or userland) that manages a single state, effect or resource, into a Hook that manages an array of said state, effects or resources. # Detailed design @@ -233,22 +233,22 @@ declare function useForEach( - `keys`: The iterable on which the loop operates. It should contain only strings and/or numbers, and should not contain duplicates. - The iterable should be a dynamic value and come from e.g. props or another hook call. - This is not a dependency array like for the `useMemo` or `useEffect` hooks, and should not be an array literal. + The iterable should be a dynamic value and come from e.g. props or another Hook call. + This is not a dependency array like for the `useMemo` or `useEffect` Hooks, and should not be an array literal. - `callback`: The function that is executed for each element in `keys`. It should be pure, should take a single `key` argument, and may return a value of any type. It may call other React Hooks. Hooks that are called inside `callback` use the passed-in `key` to track their state across multiple renders. - For example, a `useState` hook will always return the state for the same key, even if that key moves to different indexes in the `keys` iterable over multiple renders. - Likewise, a `useEffect` hook will compare the current dependencies with the previous dependencies of the same key to determine whether to execute again. + For example, a `useState` Hook will always return the state for the same key, even if that key moves to different indexes in the `keys` iterable over multiple renders. + Likewise, a `useEffect` Hook will compare the current dependencies with the previous dependencies of the same key to determine whether to execute again. If `keys` contains a new key that wasn't present in the previous render, then the Hooks for that key will be newly initialized, like it normally happens during the first render of a component. For example, `useMemo` will call its `calculateValue` callback, because there are no previous dependencies to compare yet. If `keys` doesn't contain a key that was present in the previous render, then the Hooks associated with that key are "unmounted". - Effect hooks like `useEffect` and `useSyncExternalStore` execute their cleanup; stateful hooks like `useState`, `useMemo` and `useRef` drop all references to their values. + Effect Hooks like `useEffect` and `useSyncExternalStore` execute their cleanup; stateful Hooks like `useState`, `useMemo` and `useRef` drop all references to their values. When that same key appears again in a subsequent render, then it gets newly initialized again. ### Returns @@ -296,13 +296,13 @@ function ChatApp({ roomIds }) { ``` The second code listing (with the `for ... of` loop) is not valid React code, because it violates the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). -The first code listing (with the `useForEach` hook) is valid React code, follows the Rules of Hooks, and achieves the same goal. +The first code listing (with the `useForEach` Hook) is valid React code, follows the Rules of Hooks, and achieves the same goal. ### Associating state with keys If you need to store some state for each of your keys, you have two options. -1. You can store the state for each key inside a separate state hook, like this: +1. You can store the state for each key inside a separate state Hook, like this: ```tsx function ChatApp({ roomIds }) { @@ -369,7 +369,7 @@ Which option is better depends on your use case. ### Exception handling -The `useForEach` hook does not catch errors. +The `useForEach` Hook does not catch errors. When `callback` throws an error, it will bubble up and terminate the current render. This follows the example established by the `useMemo` callback and `useState` initializer callback. @@ -378,16 +378,16 @@ This follows the example established by the `useMemo` callback and `useState` in Passing a `keys` iterable that contains duplicate values triggers a [duplicate keys](https://github.com/facebook/react/blob/a4964987dc140526702e996223fe7ee293def8ac/packages/react-reconciler/src/ReactChildFiber.js#L1070-L1077) error. The error is logged to `console.error` in development, and silently discarded in production. -For duplicate keys, React tries to match loop `callback` calls to hook state via the iteration index. +For duplicate keys, React tries to match loop `callback` calls to Hook state via the iteration index. If matching based on the index fails, the loop "instances" of the duplicate keys and the associated Hooks become "orphaned". -Orphaned state hooks can be garbage collected because they can never be read again, and orphaned effect hooks will never execute their cleanup function. +Orphaned state Hooks can be garbage collected because they can never be read again, and orphaned effect Hooks will never execute their cleanup function. See [Appendix A: React handling of duplicate keys in JSX](#appendix-a-react-handling-of-duplicate-keys-in-jsx) for a demonstration of this behaviour for JSX elements. This follows the example established by JSX keys. ### Key type coercion -To determine key equality, the `useForEach` hook internally converts all elements in the `keys` array to strings. +To determine key equality, the `useForEach` Hook internally converts all elements in the `keys` array to strings. All of the following arrays will trigger a duplicate keys error: `["1", 1]`, `[{}, {}]`, `["null", null]` This follows the example established by JSX keys. @@ -416,13 +416,13 @@ function useMultipleConnections(roomIds) { # Drawbacks -Foot gun - this hook is powerful, and as such requires a certain level of care and understanding to use properly. +Foot gun - this Hook is powerful, and as such requires a certain level of care and understanding to use properly. Improper use can cause problems like: -- performance issues due to excessive hook calls +- performance issues due to excessive Hook calls - memory leaks -The issue is even greater here than with JSX arrays because this hook will probably be used almost exclusively for effects. +The issue is even greater here than with JSX arrays because this Hook will probably be used almost exclusively for effects. Maybe this complexity should not be made more accessible, and should be left to experienced engineers who build solutions outside of React. Why should we _not_ do this? Please consider: @@ -447,11 +447,11 @@ If we implement this proposal, how will existing React developers adopt it? Is this a breaking change? Can we write a codemod? Should we coordinate with other projects or libraries? -The `uesForEach()` hook allows to +The `uesForEach()` Hook allows to # How we teach this -The `useForEach()` hook is a continuation of two established concepts in React: keys and hook composition. +The `useForEach()` Hook is a continuation of two established concepts in React: keys and Hook composition. It is an advanced concept, and fits well as its own sub-page in the [Escape Hatches](https://react.dev/learn/escape-hatches) chapter. , lifting state up, and synchronizing with effects. @@ -468,9 +468,9 @@ How should this feature be taught to existing React developers? # Unresolved questions - Implementation cost, both in term of code size and complexity. -- Is this hook compatible/composable with all other hooks? +- Is this Hook compatible/composable with all other Hooks? I have never used `useActionState`, `useDeferredValue`, `useOptimistic`, and `useTransition`. -- What is a good name for this hook? +- What is a good name for this Hook? List of ideas: - `useForEach` (from [`Array.forEach`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach)) - `useMap` (from [`Array.map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map)) From 0a18effa0ef9dffb819f38a3421f113e6e73d07d Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Tue, 17 Dec 2024 22:29:13 +0100 Subject: [PATCH 6/8] add examples in "motivation" --- text/0000-use-for-each.md | 57 ++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 2e834e49..0decb920 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -46,7 +46,7 @@ The app allows users to connect to multiple chat rooms at the same time. The UI renders one chat room at a time, and users can switch between all connected chat rooms via a [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) component. A badge over each tab tells the user if that chat room has unread messages. -image +Screenshot of a chat app with 3 connected chat rooms, organized in tabs [live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) @@ -88,8 +88,8 @@ function useSingleConnection(roomId) { } ``` -While this implementation is a decent start, it has two limitations. -First, connections are closed and re-opened whenever we switch tabs (because the `Tabs` component unmounts hidden tabs). +While this implementation is a decent start, we need to fix two issues. +First, connections are closed and re-opened whenever we switch tabs (because the `Tabs` component mounts only the active tab). Second, we can't render the "unread messages" badge count because the `ChatApp` component doesn't have access to the connection objects. To address both issues, we need to lift the connection state up into `ChatApp`. @@ -119,7 +119,7 @@ function useMultipleConnections(roomIds) { ``` Alas, we can't. -When we connect to another chat room by adding an element to `roomIds`, React throws this error: +When we connect to another chat room by adding an element to the `roomIds` array, React throws this error: > React has detected a change in the order of Hooks called by ChatApp. > This will lead to bugs and errors if not fixed. @@ -197,9 +197,9 @@ https://github.com/user-attachments/assets/a472bc84-233c-4832-9706-b980056c552c [live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) This works, but it's messy. -And it only gets worse as the effect depends on more dependencies. +And it only gets worse if the effect grows and depends on more dependencies. -### With `useForEach()` +### `useEffect` with `useForEach()` Idiomatic React is all about _composition_. Ideally, we want to compose the `useMultipleConnections()` Hook from the existing `useSingleConnection()` Hook. @@ -215,6 +215,49 @@ function useMultipleConnections(roomIds) { The Hook can effectively be used to convert any Hook (native or userland) that manages a single state, effect or resource, into a Hook that manages an array of said state, effects or resources. +### `useId` with `useForEach` + +Our chat app renders a dynamic number of tabs. +[WAI-ARIA](https://w3c.github.io/aria/#tabpanel) requires that both tabs and tab panels have an HTML `id` attribute. + +> Authors SHOULD associate a tabpanel element with its tab, by using the aria-controls attribute on the tab to reference the tab panel, and/or by using the aria-labelledby attribute on the tab panel to reference the tab. + +Therefore, we need 2×`roomIds.length` unique HTML ids. +Today, we can generate a single id prefix with `useId`, use the room ids as suffixes, and hope that a simple string concatenation results in a valid HTML id. + +With `useForEach`, we could instead generate an arbitrary number of ids that are guaranteed to be valid. + +```tsx +const ids = useForEach(roomIds, () => useId()); +``` + +### `useSyncExternalStore` with `useForEach` + +One implementation detail of `ChatApp` that I skipped over earlier is `useUnreadCounts(connections)`. +The code listing above uses this hook to read the `ChatRoomConnection#unreadCount` properties from all open connections. +Without the `useForEach` hook, this userland hook is surprisingly difficult to implement – at least, if we want to keep the results array stable until one of its elements changes. + +The `useForEach` would make this easy to implement. + +```tsx +function useUnreadCounts(connections) { + return useForEach(connections, (connection) => { + const subscribe = useCallback( + (onStoreChange) => { + const unsubscribe = connection?.subscribe("unreadCount", onStoreChange); + return () => unsubscribe?.(); + }, + [connection] + ); + const getSnapshot = useCallback( + () => connection?.readyState ?? 0, + [connection] + ); + return useSyncExternalStore(subscribe, getSnapshot); + }); +} +``` + # Detailed design ## Reference @@ -258,7 +301,7 @@ A frozen array containing the results from calling `callback` with all `keys`. The order of values inside the results array matches the order of `keys`. For example, if `keys` is `[1, 2, 3]` during one render and `[2, 1, 3]` during the next, then the first results array will be `[callback(1), callback(2), callback(3)]`, and the second will be `[callback(2), callback(1), callback(3)]`. -React will return the same array during consecutive renders, if the number of keys hasn't changed, each index in the results array contains the same value as during the previous render (as determined by `Object.is`). +React will return the same array during consecutive renders if the number of keys hasn't changed, and each index in the results array contains the same value as during the previous render (as determined by `Object.is`). To prevent inadvertent mutations that would leak into consecutive renders, the results array is [frozen](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze). ## Usage From 3cab7dcf2fa3569fdc77eeb6ded7bc7fb218d91e Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Tue, 17 Dec 2024 23:54:05 +0100 Subject: [PATCH 7/8] add listing numbers --- text/0000-use-for-each.md | 136 ++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 80 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 0decb920..02c5a487 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -26,7 +26,7 @@ function MyComponent({ keys }) { # Motivation -Once you have learned to think in React, synchronizing a _single_ external system with React is straight-forward: +Synchronizing a _single_ external system with React is straight-forward: Connect to the system inside an effect, disconnect from the system inside the cleanup of that same effect. React guarantees that effects and cleanups are executed in a well-defined, predictable order, which makes it relatively easy to reason about race conditions and memory leaks. @@ -88,8 +88,10 @@ function useSingleConnection(roomId) { } ``` +_Listing 3-1: ChatApp initial version._ + While this implementation is a decent start, we need to fix two issues. -First, connections are closed and re-opened whenever we switch tabs (because the `Tabs` component mounts only the active tab). +First, connections are closed and re-opened whenever we switch tabs (because our `Tabs` component mounts only the active tab). Second, we can't render the "unread messages" badge count because the `ChatApp` component doesn't have access to the connection objects. To address both issues, we need to lift the connection state up into `ChatApp`. @@ -118,6 +120,8 @@ function useMultipleConnections(roomIds) { } ``` +_Listing 3-2: ChatApp after lifting connection state to parent. The "useMultipleConnections" hook is erroneous._ + Alas, we can't. When we connect to another chat room by adding an element to the `roomIds` array, React throws this error: @@ -141,6 +145,8 @@ function useMultipleConnections(roomIds) { } ``` +_Listing 3-3: "useMultipleConnections" hook naive implementation that closes all connections on changes._ + With the new Hook implementation, we can make changes to the `roomIds` array without crashing the app. But with every change to the array, we now close and re-open _all_ connections. This results in flickering badges and chat content whenever the user connects to a new room, disconnects from a room, or merely moves tabs around. @@ -153,7 +159,7 @@ To avoid closing all connections whenever `roomIds` changes, we need to put the We also need to use a ref object to share the connections with the "disconnect" effect. ```tsx -function useMultipleConnectionsNonIdiomatic(roomIds) { +function useMultipleConnections(roomIds) { const connectionsRef = useRef(new Map()); const [connections, setConnections] = useState(() => new Map()); @@ -192,6 +198,8 @@ function useMultipleConnectionsNonIdiomatic(roomIds) { } ``` +_Listing 3-4: "useMultipleConnections hook implementation that doesn't close unrelated connections on change, but violates "useEffect" usage guidelines._ + https://github.com/user-attachments/assets/a472bc84-233c-4832-9706-b980056c552c [live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) @@ -213,6 +221,8 @@ function useMultipleConnections(roomIds) { } ``` +_Listing 3-5: "useMultipleConnections" implementation based on "useForEach" hook._ + The Hook can effectively be used to convert any Hook (native or userland) that manages a single state, effect or resource, into a Hook that manages an array of said state, effects or resources. ### `useId` with `useForEach` @@ -231,6 +241,8 @@ With `useForEach`, we could instead generate an arbitrary number of ids that are const ids = useForEach(roomIds, () => useId()); ``` +_Listing 3-6: Generating a dynamic number of unique HTML ids._ + ### `useSyncExternalStore` with `useForEach` One implementation detail of `ChatApp` that I skipped over earlier is `useUnreadCounts(connections)`. @@ -258,6 +270,8 @@ function useUnreadCounts(connections) { } ``` +_Listing 3-7: "useMultipleConnections" implementation based on "useForEach" hook._ + # Detailed design ## Reference @@ -323,6 +337,8 @@ function ChatApp({ roomIds }) { } ``` +_Listing 4-1: Execute an array of effects with "useForEach"._ + You can think of this code as being equivalent to this: ```tsx @@ -338,8 +354,10 @@ function ChatApp({ roomIds }) { } ``` -The second code listing (with the `for ... of` loop) is not valid React code, because it violates the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). -The first code listing (with the `useForEach` Hook) is valid React code, follows the Rules of Hooks, and achieves the same goal. +_Listing 4-2: Explaining useForEach in terms of a simple "for ... of" loop._ + +Code listing 4-2 is not valid React code, because it violates the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). +Code listing 4-1 is valid React code, follows the Rules of Hooks, and results in the same runtime behaviour. ### Associating state with keys @@ -350,8 +368,8 @@ If you need to store some state for each of your keys, you have two options. ```tsx function ChatApp({ roomIds }) { const connections = useForEach(roomIds, (roomId) => { - const connections = useEffect(() => { - const [conn, setConn] = useState(null); // <- State variable that stores a single connection object. + const [conn, setConn] = useState(null); // <- State variable that stores a single connection object. + useEffect(() => { const connection = createConnection(roomId); setConn(connection); // <- Write to the local state variable. return () => { @@ -359,11 +377,13 @@ If you need to store some state for each of your keys, you have two options. setConn(null); }; }, [roomId]); - return conn; // <- Pass the local variable to the parent scope. + return conn; // <- Pass the local variable to the component scope. }); } ``` + _Listing 4-3: Creating local state inside the "useForEach" callback._ + Call `useState` inside the `useForEach` callback to create a local state variable. Write to that state within the effect, and return the state from the callback function. `useForEach` returns an array of all the callback results. @@ -377,13 +397,15 @@ If you need to store some state for each of your keys, you have two options. } ``` + _Listing 4-4: Mapping "useForEach" results to keys by index._ + 2. Alternatively, you can store all values in a single state variable, inside a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or object. ```tsx function ChatApp({ roomIds }) { const [connections, setConnections] = useState({}); // <- State variable that stores all connections. useForEach(roomIds, (roomId) => { - const connections = useEffect(() => { + useEffect(() => { const connection = createConnection(roomId); setConnections((prev) => ({ ...prev, [roomId]: connection })); // <- Write to the component-level state. return () => { @@ -396,6 +418,8 @@ If you need to store some state for each of your keys, you have two options. } ``` + _Listing 4-5: Accessing component state from within the "useForEach" callback._ + To find the connection corresponding to a specific key, index the map by that key: ```tsx @@ -404,6 +428,8 @@ If you need to store some state for each of your keys, you have two options. } ``` + _Listing 4-6: Mapping keys to state created by an effect inside "useForEach" callback._ + Option 1 gives you an array of values, where the element order is guaranteed to match the `keys` order of the current render. Option 2 gives you a map from key to value, but the iteration order of the map may get out of sync with the `keys` iteration order over time. Which option is better depends on your use case. @@ -435,84 +461,44 @@ All of the following arrays will trigger a duplicate keys error: `["1", 1]`, `[{}, {}]`, `["null", null]` This follows the example established by JSX keys. -## Sharing state between iterations - -```tsx -function useMultipleConnections(roomIds) { - const [connections, setConnections] = useState({}); - useForEach(roomIds, (roomId) => { - useEffect(() => { - const connection = createConnection(roomId); - setConnections((prev) => ({ ...prev, [roomId]: connection })); - return () => { - connection.disconnect(); - setConn((prev) => { - const { [roomId]: _, ...rest } = { ...prev }; - return rest; - }); - }; - }, [roomId]); - }); - return connections; -} -``` - # Drawbacks -Foot gun - this Hook is powerful, and as such requires a certain level of care and understanding to use properly. -Improper use can cause problems like: - -- performance issues due to excessive Hook calls -- memory leaks - -The issue is even greater here than with JSX arrays because this Hook will probably be used almost exclusively for effects. -Maybe this complexity should not be made more accessible, and should be left to experienced engineers who build solutions outside of React. - -Why should we _not_ do this? Please consider: - -- implementation cost, both in term of code size and complexity -- whether the proposed feature can be implemented in user space -- the impact on teaching people React -- integration of this feature with other existing and planned features -- cost of migrating existing React applications (is it a breaking change?) - -There are tradeoffs to choosing any path. Attempt to identify them here. +- The duplicate key behaviour is a footgun. +- Identifying when to use this Hook over other solutions might be difficult for React beginners. + Similar to how beginners often use `useState` + `useEffect` instead of `useMemo`, this Hook could open the door to a new wave of Hook misuse. # Alternatives -Handle collections of state and/or effects outside of React, then synchronize them with React via `useEffect` or `useSyncExternalStore`. - -What other designs have been considered? What is the impact of not doing this? +- Alternatives for Hook composition with loops in userland: none +- Alternative for managing an array of external resources: + Two separate `useEffect` hooks for allocation and cleanup, plus a `useRef` hook; see listing 3-4. + This code is non-idiomatic an error-prone. +- Alternative for allocating and closing resources: + Writing your own resource manager. + This resource manager must still be synchronized with React. # Adoption strategy -If we implement this proposal, how will existing React developers adopt it? Is -this a breaking change? Can we write a codemod? Should we coordinate with -other projects or libraries? - -The `uesForEach()` Hook allows to +Not a breaking change. +This can be shipped in a minor release. # How we teach this The `useForEach()` Hook is a continuation of two established concepts in React: keys and Hook composition. -It is an advanced concept, and fits well as its own sub-page in the [Escape Hatches](https://react.dev/learn/escape-hatches) chapter. -, lifting state up, and synchronizing with effects. - -What names and terminology work best for these concepts and why? How is this -idea best presented? As a continuation of existing React patterns? - -Would the acceptance of this proposal mean the React documentation must be -re-organized or altered? Does it change how React is taught to new developers -at any level? - -How should this feature be taught to existing React developers? +On the React learning path, it places somewhere between `useEffect` and `useSyncExternalStore`: +It is useful for advanced use cases, but not necessary knowledge to be productive with React. +There is no need to cover this Hook in the React tutorial or other beginner documentation. +It probably fits well as its own sub-page in the [Escape Hatches](https://react.dev/learn/escape-hatches) chapter. # Unresolved questions - Implementation cost, both in term of code size and complexity. +- Will this Hook cause any conflicts with background rendering, triggered by `useTransition`? - Is this Hook compatible/composable with all other Hooks? I have never used `useActionState`, `useDeferredValue`, `useOptimistic`, and `useTransition`. +- How does this Hook interact with RSCs? + I have never worked with RSCs, I only built React SPAs. - What is a good name for this Hook? List of ideas: - `useForEach` (from [`Array.forEach`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach)) @@ -534,9 +520,6 @@ https://github.com/user-attachments/assets/eef30d1f-f54b-4eee-96c1-c239f258aaea [live demo](https://pschiffmann.github.io/use-for-each-playground/appendix-a-unique-keys.html) -
-Listing 10-1: A well-behaving React app with unique keys. - ```tsx import { useEffect, useState } from "react"; @@ -577,7 +560,7 @@ function Child({ value }) { } ``` -
+_Listing 10-1: A well-behaving React app with unique keys._ The "show/hide" button mounts/unmounts a list of four children `A`, `B`, `C`, `Z`. The "Z >> 1" button moves the `Z` child to the next position in the list, wrapping around after the last position. @@ -603,9 +586,6 @@ https://github.com/user-attachments/assets/7636308c-ae68-4d87-b16f-326904b79a65 [live demo](https://pschiffmann.github.io/use-for-each-playground/appendix-a-duplicate-keys.html) -
-Listing 10-2: A React app with glitches caused by duplicate keys. - ```tsx import { useEffect, useState } from "react"; @@ -646,11 +626,7 @@ function Child({ value }) { } ``` -
+_Listing 10-2: A React app with glitches caused by duplicate keys._ We can see that React doesn't properly unmount all elements, and also doesn't run all effect cleanup callbacks. In a real application, this can lead to UI glitches with duplicated UI elements, and possibly memory leaks due to external resources that are allocated but never cleaned up. - -## Appendix B: Managing an array of external resources with `useEffect` - -## Appendix C: `useSyncExternalStore` to access an array of external states From c9bdd2a21557afd6656c855403ff9b437212c80c Mon Sep 17 00:00:00 2001 From: Philipp Schiffmann Date: Wed, 18 Dec 2024 11:59:09 +0100 Subject: [PATCH 8/8] wording adjustments --- text/0000-use-for-each.md | 65 ++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/text/0000-use-for-each.md b/text/0000-use-for-each.md index 02c5a487..79b6d8df 100644 --- a/text/0000-use-for-each.md +++ b/text/0000-use-for-each.md @@ -26,31 +26,31 @@ function MyComponent({ keys }) { # Motivation -Synchronizing a _single_ external system with React is straight-forward: +[Synchronizing a _single_ external system](https://react.dev/learn/synchronizing-with-effects) with React is straight-forward: Connect to the system inside an effect, disconnect from the system inside the cleanup of that same effect. React guarantees that effects and cleanups are executed in a well-defined, predictable order, which makes it relatively easy to reason about race conditions and memory leaks. -Unfortunately, we can't carry over this mental model if we need to synchronize _a dynamic number_ of external systems. +Unfortunately, we can't apply this mental model if we need to synchronize _a dynamic number_ of external systems. The natural way to process multiple values is to iterate over them, but loops and Hooks don't compose: 1. Placing the `useEffect` call inside a `for ... of` loop is forbidden by the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). -2. Placing the `for ... of` loop inside the `useEffect` call will execute the cleanup function for _all_ elements whenever _any_ element changes. +2. Placing the `for ... of` loop inside the `useEffect` callback will execute the cleanup function for _all_ elements whenever _any_ element changes. -Today, applications that need to connect to a dynamic number of external systems have no other choice than to use non-idiomatic workarounds. +Today, applications that need to connect to a dynamic number of external systems must use non-idiomatic workarounds. This increases the risk of race conditions and memory leaks, makes the code harder to read, and causes code duplication if both single-connection and multi-connection Hooks are needed for the same external system. ## ChatRooms example To give a specific example, we will look at a simple chat app. The app allows users to connect to multiple chat rooms at the same time. -The UI renders one chat room at a time, and users can switch between all connected chat rooms via a [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) component. +The UI renders one chat room at a time, and users can switch between all connected chat rooms via a tabs component. A badge over each tab tells the user if that chat room has unread messages. Screenshot of a chat app with 3 connected chat rooms, organized in tabs [live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) -Connecting to a single chat room is pretty straight-forward, and is covered in great detail in the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) docs. +Connecting to a single chat room is done with a simple `useEffect`, and is covered in great detail in the [Lifecycle of React Effects](https://react.dev/learn/lifecycle-of-reactive-effects) docs. We can use the `ChatRoom` component from the docs as a starting point, and render one `ChatRoom` per tab. ```tsx @@ -80,7 +80,7 @@ function useSingleConnection(roomId) { const connection = new ChatRoomConnection(roomId); setConn(connection); return () => { - connection.disconnect(); + connection.close(); setConn(null); }; }, [roomId]); @@ -91,7 +91,7 @@ function useSingleConnection(roomId) { _Listing 3-1: ChatApp initial version._ While this implementation is a decent start, we need to fix two issues. -First, connections are closed and re-opened whenever we switch tabs (because our `Tabs` component mounts only the active tab). +First, connections are closed and re-opened whenever we switch tabs (because our `Tabs` component mounts only the visible tab, and unmounts all others). Second, we can't render the "unread messages" badge count because the `ChatApp` component doesn't have access to the connection objects. To address both issues, we need to lift the connection state up into `ChatApp`. @@ -120,7 +120,7 @@ function useMultipleConnections(roomIds) { } ``` -_Listing 3-2: ChatApp after lifting connection state to parent. The "useMultipleConnections" hook is erroneous._ +_Listing 3-2: ChatApp after lifting connection state to parent. The "useMultipleConnections" Hook is erroneous._ Alas, we can't. When we connect to another chat room by adding an element to the `roomIds` array, React throws this error: @@ -145,7 +145,7 @@ function useMultipleConnections(roomIds) { } ``` -_Listing 3-3: "useMultipleConnections" hook naive implementation that closes all connections on changes._ +_Listing 3-3: "useMultipleConnections" Hook naive implementation that closes all connections on changes._ With the new Hook implementation, we can make changes to the `roomIds` array without crashing the app. But with every change to the array, we now close and re-open _all_ connections. @@ -198,20 +198,20 @@ function useMultipleConnections(roomIds) { } ``` -_Listing 3-4: "useMultipleConnections hook implementation that doesn't close unrelated connections on change, but violates "useEffect" usage guidelines._ +_Listing 3-4: "useMultipleConnections" Hook implementation that doesn't close unrelated connections on change, but violates "useEffect" usage guidelines._ https://github.com/user-attachments/assets/a472bc84-233c-4832-9706-b980056c552c [live demo](https://pschiffmann.github.io/use-for-each-playground/chat-app-non-idiomatic.html) | [source code](https://github.com/pschiffmann/use-for-each-playground/blob/main/src/chat-app/main-non-idiomatic.tsx) -This works, but it's messy. -And it only gets worse if the effect grows and depends on more dependencies. +This Hook works as expected, keeping connections open when the element order of the `roomIds` array changes, or a single connection is added or removed. +But the implementation doesn't follow idiomatic React patterns and is significantly harder to reason about than the `useSingleConnection` Hook. ### `useEffect` with `useForEach()` Idiomatic React is all about _composition_. -Ideally, we want to compose the `useMultipleConnections()` Hook from the existing `useSingleConnection()` Hook. -The `useForEach()` Hook lets us do just that. +Ideally, we want to compose the `useMultipleConnections()` Hook from the existing `useSingleConnection()` Hook, as seen in listing 3-2. +The `useForEach()` Hook lets us do exactly that. ```tsx function useMultipleConnections(roomIds) { @@ -221,7 +221,7 @@ function useMultipleConnections(roomIds) { } ``` -_Listing 3-5: "useMultipleConnections" implementation based on "useForEach" hook._ +_Listing 3-5: "useMultipleConnections" implementation based on "useForEach" Hook._ The Hook can effectively be used to convert any Hook (native or userland) that manages a single state, effect or resource, into a Hook that manages an array of said state, effects or resources. @@ -235,21 +235,24 @@ Our chat app renders a dynamic number of tabs. Therefore, we need 2×`roomIds.length` unique HTML ids. Today, we can generate a single id prefix with `useId`, use the room ids as suffixes, and hope that a simple string concatenation results in a valid HTML id. -With `useForEach`, we could instead generate an arbitrary number of ids that are guaranteed to be valid. +With `useForEach`, we can instead generate an arbitrary number of ids that are guaranteed to be valid. ```tsx -const ids = useForEach(roomIds, () => useId()); +const ids = useForEach(roomIds, () => ({ + tabId: useId(), + tabPanelId: useId(), +})); ``` _Listing 3-6: Generating a dynamic number of unique HTML ids._ ### `useSyncExternalStore` with `useForEach` -One implementation detail of `ChatApp` that I skipped over earlier is `useUnreadCounts(connections)`. -The code listing above uses this hook to read the `ChatRoomConnection#unreadCount` properties from all open connections. -Without the `useForEach` hook, this userland hook is surprisingly difficult to implement – at least, if we want to keep the results array stable until one of its elements changes. +One implementation detail of `ChatApp` that we skipped over earlier is `useUnreadCounts(connections)`. +Listing 3-2 uses this Hook to read the `connection.unreadCount` properties from all open connections. +Without the `useForEach` Hook, this userland Hook is surprisingly difficult to implement – at least, if we want to keep the results array stable until one of its elements changes. -The `useForEach` would make this easy to implement. +The `useForEach` Hook makes this easy to implement. ```tsx function useUnreadCounts(connections) { @@ -270,7 +273,7 @@ function useUnreadCounts(connections) { } ``` -_Listing 3-7: "useMultipleConnections" implementation based on "useForEach" hook._ +_Listing 3-7: "useUnreadCounts" implementation based on "useForEach" Hook._ # Detailed design @@ -297,11 +300,11 @@ declare function useForEach( It should be pure, should take a single `key` argument, and may return a value of any type. It may call other React Hooks. - Hooks that are called inside `callback` use the passed-in `key` to track their state across multiple renders. + Hooks that are called inside `callback` use the `key` of the current iteration to track their internal state across multiple renders. For example, a `useState` Hook will always return the state for the same key, even if that key moves to different indexes in the `keys` iterable over multiple renders. Likewise, a `useEffect` Hook will compare the current dependencies with the previous dependencies of the same key to determine whether to execute again. - If `keys` contains a new key that wasn't present in the previous render, then the Hooks for that key will be newly initialized, like it normally happens during the first render of a component. + If `keys` contains a new key that wasn't present in the previous render, then the Hooks for that key will be newly initialized, in the same way as top level Hooks are initialized during the first component render. For example, `useMemo` will call its `calculateValue` callback, because there are no previous dependencies to compare yet. If `keys` doesn't contain a key that was present in the previous render, then the Hooks associated with that key are "unmounted". @@ -357,7 +360,7 @@ function ChatApp({ roomIds }) { _Listing 4-2: Explaining useForEach in terms of a simple "for ... of" loop._ Code listing 4-2 is not valid React code, because it violates the [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks). -Code listing 4-1 is valid React code, follows the Rules of Hooks, and results in the same runtime behaviour. +Code listing 4-1 is valid React code, follows the Rules of Hooks, but otherwise results in the same runtime behaviour. ### Associating state with keys @@ -461,6 +464,12 @@ All of the following arrays will trigger a duplicate keys error: `["1", 1]`, `[{}, {}]`, `["null", null]` This follows the example established by JSX keys. +### Nesting `useForEach` calls + +All native and userland Hooks may be called inside the `useForEach` callback, including other `useForEach` Hooks. +This allows processing deeply nested data structures with Hooks. +But more importantly, it preserves one of Hooks greatest features – composability. + # Drawbacks - The duplicate key behaviour is a footgun. @@ -471,8 +480,8 @@ This follows the example established by JSX keys. - Alternatives for Hook composition with loops in userland: none - Alternative for managing an array of external resources: - Two separate `useEffect` hooks for allocation and cleanup, plus a `useRef` hook; see listing 3-4. - This code is non-idiomatic an error-prone. + Two separate `useEffect` Hooks for allocation and cleanup, plus a `useRef` Hook; see listing 3-4. + This approach is non-idiomatic, error-prone, and not reusable. - Alternative for allocating and closing resources: Writing your own resource manager. This resource manager must still be synchronized with React.