From 69385797ec5ebd91cb9de3e2a144fb2f1abb6179 Mon Sep 17 00:00:00 2001 From: Reed Harmeyer Date: Mon, 6 Feb 2023 20:44:25 -0800 Subject: [PATCH 1/7] Initial commit for Hoisting RFC --- text/0000-hoisting-api.md | 621 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 text/0000-hoisting-api.md diff --git a/text/0000-hoisting-api.md b/text/0000-hoisting-api.md new file mode 100644 index 00000000..72d33182 --- /dev/null +++ b/text/0000-hoisting-api.md @@ -0,0 +1,621 @@ +- Start Date: 2023-01-31 +- RFC PR: (leave this empty) +- React Issue: (leave this empty) + +# Summary + +Introduce a new built-in API for creating hooks which will be automatically hoisted. +This would address the desire for simplified state sharing in a more composable and flexible manner. +Currently, calling this `createStore`. + +# Basic example + +I'm going to build off of [Scaling Up With Reducer and Context]( +https://beta.reactjs.org/learn/scaling-up-with-reducer-and-context) +from the Beta docs. + +### TasksContext.js +```diff +- const TasksContext = createContext (null) +- const TasksDispatchContext = createContext (null) +- + const INITIAL = [ + // ... + ] + + const reducer = (tasks, action) => { + // ... + } + ++ export const TasksProvider = Fragment +- export const TasksProvider = ({ children }) => { +- const [tasks, dispatch] = useReducer (reducer, INITIAL) +- return ( +- +- +- {children} +- +- +- ) +- } ++ ++ const useTasksReducer = hoist (() => useReducer (reducer, INITIAL)) + +- export const useTasks = () => useContext (TasksContext) ++ export const useTasks = hoist (() => useTasksReducer()[0]) + +- export const useTasksDispatch = () => useContext (TasksDispatchContext) ++ export const useTasksDispatch = hoist (() => useTasksReducer()[1]) +``` + +### TaskList.js +```diff +- const useTaskIds = () => { ++ const useTaskIds = hoist (() => { + const tasks = useTasks () + return useMemo (() => { + return tasks.map (t => t.id) + }, [ tasks ]) ++ }) +- } + + export default function TaskList() { + const ids = useTaskIds () + const items = ids.map (id => ( + + + + )) + return <>{items} + } +``` + +### Task.js (new!) +```diff +export const TaskIdContext = createContext () + +- const useTaskProperty = (key) => { ++ const useTaskProperty = hoist ((key) => { + const id = useContext (TaskIdContext) + const tasks = useTasks () + + return useMemo (() => { + const task = tasks.find (t => t.id === id) + return task[key] + }, [ tasks, id, key ]) ++ }) +- } + + const useTaskEditingState = hoist (() => { + + }) + +- export default function Task () { +- const [isEditing, setIsEditing] = useState (false) +- const dispatch = useTaskDispatch () +- const text = useTaskProperty ("text") +- const done = useTaskProperty ("done") +- + return ( +
+ + + +
+ ) +- } +``` + +--- + +```tsx +import { IdContext, useExpandedState } from "./state" + +function ExpandedContent () { + const [ expanded ] = useExpandedState() + if (!expanded) return null + // ... +} + +function ExpandButton () { + const [ expanded, setExpanded ] = useExpandedState() + // ... +} + +function Item () { + return <> + + + + +} + +export function List ({ ids }) { + return <> + {ids.map (id => ( + + + + ))} + +} +``` + +## Global State + +```tsx +const useExpandedStateById = hoist ((id: string) => { + useAssertConstant (id) + return useState (false) +}) + +export const IdContext = createContext ("") + +export const useExpandedState = () => { + const id = useContext (TodoIdContext) + return useExpandedStateById (id) +} +``` + +## Item-Level State + +```tsx +export const IdContext = createContext ("") + +const useExpandedState = hoist (() => { + const id = useContext (IdContext) + useAssertConstant (id) + return useState (false) +}) +``` + +# Motivation + +The Provider pattern is our de-facto solution to hoisted state but it has a fundamental limitation: +we cannot add Providers during the runtime without side effects caused by re-mounting the tree. + +Situations where Providers are insufficient: +- Lazily loading hoisted state like for Micro-Frontends or plugins. +- Performance when hoisting state of list items. + +This approach has some ergonomic benefits as well: +- Context can be used for more primitive/intrinsic values. + - This is a more natural role for Context. +- Business logic can be explicitly imported by it's consumer. +- More compatible with Suspense. +- No duplication necessary. +- Avoiding re-renders from derived state is trivial. + - Pattern is resilient to duplication. + +I think this could potentially become the industry standard over third-party libraries. + +## Backstory + +I was trying to figure out how to share state between components that are distributed and loaded dynamically +using Module Federation, which the Provider pattern was highly incompatible with. +I started with Recoil which worked well for global state but I also needed state that was hoisted but not global. + +I ended up creating a new atomic state library one which could have state that is contextual instead of global. +After iterating on the API for a while, I realized that I was approaching hooks and +that this would be better integrated with React given it's reliance on Context. + +# Detailed design + +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. +--- + + + +# Drawbacks + +- The implementation would be non-trivial. +- Somewhat changes the meaning of Context, arguably. +- Educating developers on an additional concept and API. +- Somewhat overlaps with what already exists. +- This is yet another state management solution. +- Unsure of any conflicts their might be with lesser known features. + +# Alternatives + +What other designs have been considered? What is the impact of not doing this? +--- + +It's not possible to do this with hooks as an external library, +the closest I've been able to get is making a Recoil-like library which is substantially lesser than full hooks support. + +# 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? + +--- + +I think this is a similar kind of change to when hooks were added. +It's a significant addition that would effect how React developers write their code +but it's not a breaking change to the library and it can be adopted incrementally. + +# How we teach this + +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? +--- + +## Terminology + +I'm using the term Store currently by default. More than open to changing it. +I experimented with just calling it `hoist` but I found that to be a bit hand-wavy; +I think `hoist` implies that the referential integrity of the parameters wouldn't matter but it would have to. + +## Presentation + +The first 8 minutes on this video about Recoil do a fantastic job of talking about this problem: +https://www.youtube.com/watch?v=_ISAA_Jt9kI&t=3s. +We present the (prop-less) Provider pattern and it's limitations. +Then we "extract" the Provider from the tree which would be just like a "store". +Then we present it again from the "bottom-up" +to show how this we can think about components using these stores as composition. +I would adapt that talk to include the big differences from Recoil to the proposed API: hooks and context. + +## Documentation + +This would involve changes to the React documentation, mostly additions. +I think this would become a major part of the suggested approach to React development. +I assume the Provider pattern isn't more prominently featured because it's a +pattern as opposed to an API and not a particularly popular one. +I expect this being an API makes it much more suitable as a best practice. + +# Unresolved questions + +- Does this conflict with lesser-known React APIs current or future? +- What would need to change internally to support this? +- Is it okay if it's always memoized? +- What would be needed for this to fit the React team's vision? +(see: https://github.com/reactjs/rfcs/pull/130#issuecomment-901475687) + + + + + +# Full Example + +I've added this section with the real world example that led me to this idea. + + + +## Use Case + +Here's the features, this will be true for every example. The code will be the +same unless specified otherwise. + +### ./provider +```tsx +export const ColorContext = createContext ("") +export const ProductIdContext = createContext ("") + +// TODO: add any wrappers as necessary +export const ProductProvider = ({ children, id }) => ( + + {children} + +) +``` + +### ./label + +Exogenous to the example. +`ProductLabel` displays basic product information. + +### ./data + +Exogenous to the example. +Hooks which use ProductIdContext and return data from the server. + +### ./color/state.tsx +```tsx +export const useSelectedColorState = () => { + // TODO + return { selectedColor, setSelectedColor } +} +``` + +### ./color/swatch.tsx +```tsx +const useColorSelected = () => { + const color = useContext (ColorContext) + const { selectedColor } = useSelectedColorState () + return color === selectedColor +} + +// doesn't use ColorContext for example purposes +const useSelectColor = (color: string) => { + const { setSelectedColor } = useSelectedColorState () + return useCallback (() => { + setSelectedColor (color) + }, [ color, setSelectedColor ]) +} + +export function Swatch ({ color }) { + const selected = useColorSelected () + const onClick = useSelectColorId (colorId) + // ... +} +``` + +### ./color/components.tsx +```tsx +import { useProductColors, useProductColorImageUrl } from "../data" + +export function ColorField () { + const colors = useProductColors () + return ( +
+ {colors.map (color => ( + + ))} +
+ ) +} + +export function ProductImage () { + const { selectedColor } = useSelectedColorState () + const src = useProductColorImageUrl (selectedColor) + return +} +``` + +### ./card.tsx +```tsx +import { ProductLabel } from "./label" +import { ColorField, ProductImage } from "./color/components" + +export const ProductCard = () => ( +
+ + + +
+) +``` + +### ./page.tsx +```tsx +import { ProductLabel } from "./label" +import { ColorField, ProductImage } from "./color/components" +import { Card } from "./card" +import { useRecommendations } from "./data" + +const Recommendations = () => { + const ids = useRecommendations () + const cards = ids.map (id => ( + + + + )) + return <>{cards} +} + +const ProductPage = ({ id }) => ( + +
+
+ +
+
+ + +
+
+ +
+
+ +) +``` + + + + +## Current Closest Equivalent + +Here's the closest I can get with our current toolset. It is debatably +desirable for ergonomic reasons but it still has all the limitations of Context +and in-practice the scopes are implicitly coupled to a context. + +### react-hoisting (fake package) +```tsx +function createScope () { + const providers = [] + + const component = ({ children }) => { + let content = children + + for (const Provider of providers) { + content = {content} + } + + return content + } + + const hoist = (hook) => { + const TheContext = createContext () + + providers.push (({ children }) => { + const value = hook () + return ( + + {children} + + ) + }) + + return () => useContext (TheContext) + } + + return { + Provider: component, + hoist, + } +} +``` + +### ./provider +```tsx +import { createScope } from "react-hoisting" + +export const ColorContext = createContext ("") +export const ProductIdContext = createContext ("") + +export const ProductScope = createScope () + +export const ProductProvider = ({ children, id }) => { + return ( + + + {children} + + + ) +} +``` + +### ./color/state.tsx +```tsx +import { ProductIdContext, ProductScope } from "./provider" + +export const useSelectedColorState = ProductScope.hoist (() => { + const productId = useContext (ProductIdContext) + const [ selectedColorId, setSelectedColorId ] = useState ("") + + useEffect (() => { + setSelectedColor ("") + }, [ productId ]) + + return useMemo (() => ({ + selectedColorId, + setSelectedColorId, + }), [ selectedColorId, setSelectedColorId ]) +}) +``` + + + + +## Conservative Built-In APIs + +### ./provider +```tsx +import { createScope } from "react" + +export const ProductIdContext = createContext ("") +export const ProductScope = createScope () + +export const ProductProvider = ({ children, id }) => { + return ( + + + {content} + + + ) +} +``` + +### ./color/state.tsx +```tsx +import { ProductIdContext, ProductScope } from "./provider" + +const SelectedColorStore = createStore (() => { + const productId = useContext (ProductIdContext) + const [ selectedColorId, setSelectedColorId ] = useState ("") + + useEffect (() => { + setSelectedColor ("") + }, [ productId ]) + + return useMemo (() => ({ + selectedColorId, + setSelectedColorId, + }), [ selectedColorId, setSelectedColorId ]) +}, [ ProductScope ]) + +export const useSelectedColorState = () => useStore (SelectedColorStore) +``` + +### ./color/swatch.tsx +```tsx +const ColorSelectedStore = () => { + const color = useContext (ColorContext) + const { selectedColor } = useSelectedColorState () + return color === selectedColor +} + +const SelectColorStore = (color: string) => { + const { setSelectedColor } = useSelectedColorState () + return useCallback (() => { + setSelectedColor (color) + }, [ color, setSelectedColor ]) +} + +export function Swatch () { + const color = useContext () + const onClick = useSelectColor (colorId) + + const selected = useColorSelected () + // ... +} +``` + + + + +## Aggressive Built-In API + +With a built-in API we get the following benefits: +- No Scope needed, using Context gets the info we need. +- Better throwing of Errors/Promises +- We can support arguments + +### ./color/state +```tsx +import { ProductIdContext } from "./provider" + +export const useSelectedColorState = hoist (() => { + const productId = useContext (ProductIdContext) + const [ selectedColorId, setSelectedColorId ] = useState ("") + + useEffect (() => { + setSelectedColor ("") + }, [ productId ]) + + return useMemo (() => ({ + selectedColorId, + setSelectedColorId, + }), [ selectedColorId, setSelectedColorId ]) +}) +``` + +### ./color/swatch.tsx +```tsx +const useColorSelected = hoist ((color: string) => { + const { selectedColor } = useSelectedColorState () + return color === selectedColor +}) + +const useSelectColor = hoist ((color: string) => { + const { setSelectedColor } = useSelectedColorState () + return useCallback (() => { + setSelectedColor (color) + }, [ color, setSelectedColor ]) +}) +``` From 162ed5cd3dcd21b64685a381dcd07008388dfa4e Mon Sep 17 00:00:00 2001 From: Reed Harmeyer Date: Tue, 7 Feb 2023 12:13:14 -0800 Subject: [PATCH 2/7] Update RFC doc --- text/0000-hoisting-api.md | 262 +++++++++----------------------------- 1 file changed, 62 insertions(+), 200 deletions(-) diff --git a/text/0000-hoisting-api.md b/text/0000-hoisting-api.md index 72d33182..c0969aeb 100644 --- a/text/0000-hoisting-api.md +++ b/text/0000-hoisting-api.md @@ -6,107 +6,11 @@ Introduce a new built-in API for creating hooks which will be automatically hoisted. This would address the desire for simplified state sharing in a more composable and flexible manner. -Currently, calling this `createStore`. # Basic example -I'm going to build off of [Scaling Up With Reducer and Context]( -https://beta.reactjs.org/learn/scaling-up-with-reducer-and-context) -from the Beta docs. - -### TasksContext.js -```diff -- const TasksContext = createContext (null) -- const TasksDispatchContext = createContext (null) -- - const INITIAL = [ - // ... - ] - - const reducer = (tasks, action) => { - // ... - } - -+ export const TasksProvider = Fragment -- export const TasksProvider = ({ children }) => { -- const [tasks, dispatch] = useReducer (reducer, INITIAL) -- return ( -- -- -- {children} -- -- -- ) -- } -+ -+ const useTasksReducer = hoist (() => useReducer (reducer, INITIAL)) - -- export const useTasks = () => useContext (TasksContext) -+ export const useTasks = hoist (() => useTasksReducer()[0]) - -- export const useTasksDispatch = () => useContext (TasksDispatchContext) -+ export const useTasksDispatch = hoist (() => useTasksReducer()[1]) -``` - -### TaskList.js -```diff -- const useTaskIds = () => { -+ const useTaskIds = hoist (() => { - const tasks = useTasks () - return useMemo (() => { - return tasks.map (t => t.id) - }, [ tasks ]) -+ }) -- } - - export default function TaskList() { - const ids = useTaskIds () - const items = ids.map (id => ( - - - - )) - return <>{items} - } -``` - -### Task.js (new!) -```diff -export const TaskIdContext = createContext () - -- const useTaskProperty = (key) => { -+ const useTaskProperty = hoist ((key) => { - const id = useContext (TaskIdContext) - const tasks = useTasks () - - return useMemo (() => { - const task = tasks.find (t => t.id === id) - return task[key] - }, [ tasks, id, key ]) -+ }) -- } - - const useTaskEditingState = hoist (() => { - - }) - -- export default function Task () { -- const [isEditing, setIsEditing] = useState (false) -- const dispatch = useTaskDispatch () -- const text = useTaskProperty ("text") -- const done = useTaskProperty ("done") -- - return ( -
- - - -
- ) -- } -``` - ---- +Here is a contrived example with an item in a list that can be expanded. +For a more real world use case see the end of the RFC. ```tsx import { IdContext, useExpandedState } from "./state" @@ -141,11 +45,30 @@ export function List ({ ids }) { } ``` +## Hoisted to Item-Level + +With this version, the expanded state is shared just between the descendents of +the `IdContext.Provider`. If that provider were to unmount all of the expanded +states would also be unmounted. + +```tsx +export const IdContext = createContext ("") + +const useExpandedState = hoist (() => { + const id = useContext (IdContext) + useAssertConstant (id) + return useState (false) +}) +``` + ## Global State +With this version, the expanded state would be sharable across all components +and would persist for the length of the session. + ```tsx const useExpandedStateById = hoist ((id: string) => { - useAssertConstant (id) + useAssertConstant (id) // never return useState (false) }) @@ -157,18 +80,6 @@ export const useExpandedState = () => { } ``` -## Item-Level State - -```tsx -export const IdContext = createContext ("") - -const useExpandedState = hoist (() => { - const id = useContext (IdContext) - useAssertConstant (id) - return useState (false) -}) -``` - # Motivation The Provider pattern is our de-facto solution to hoisted state but it has a fundamental limitation: @@ -199,33 +110,60 @@ I ended up creating a new atomic state library one which could have state that i After iterating on the API for a while, I realized that I was approaching hooks and that this would be better integrated with React given it's reliance on Context. -# Detailed design +# Detailed design (TODO) 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. ---- +--- +Please refer to the Full Example section at the end of the RFC. # Drawbacks - The implementation would be non-trivial. -- Somewhat changes the meaning of Context, arguably. +- Arguably, a retcon of the Context API. +- Can be used in a way that behavior depends on memoization. - Educating developers on an additional concept and API. - Somewhat overlaps with what already exists. - This is yet another state management solution. - Unsure of any conflicts their might be with lesser known features. +## Global State + +While `hoist` (as I've described it) could be used like `useGlobalState` (which +has previously been rejected) we could alter the API to prevent that. I'm not +convinced that would be desirable but it is an option. + # Alternatives What other designs have been considered? What is the impact of not doing this? + --- -It's not possible to do this with hooks as an external library, -the closest I've been able to get is making a Recoil-like library which is substantially lesser than full hooks support. +The status quo is viable for React, this is more of an opportunity. The issues +addressed by `hoist` are some of the most prominent and + +## Similar API + +There are a number of ways to implement a hoisting API that works in a similar +way but with different syntax. `hoist` as I've described here is arguably, a +bit too "auto-magical" and that could be confusing. + +## Provider API + +If we had an API for making Context Providers that could be added lazily +without unmounting the tree underneath that would unlock addressing some of the +problems that `hoist` is addressing but it would not be close to equivalent. + +## Atomic Library + +The only alternative to a built-in API is a Recoil-like library. I have created +one and will hopefully be able to open source it soon. Still, that isn't quite +equal to having that full hooks and Context integration of a built-in API. # Adoption strategy @@ -251,13 +189,11 @@ at any level? How should this feature be taught to existing React developers? --- -## Terminology +## Terminology (TODO) + -I'm using the term Store currently by default. More than open to changing it. -I experimented with just calling it `hoist` but I found that to be a bit hand-wavy; -I think `hoist` implies that the referential integrity of the parameters wouldn't matter but it would have to. -## Presentation +## Presentation (TODO) The first 8 minutes on this video about Recoil do a fantastic job of talking about this problem: https://www.youtube.com/watch?v=_ISAA_Jt9kI&t=3s. @@ -267,7 +203,7 @@ Then we present it again from the "bottom-up" to show how this we can think about components using these stores as composition. I would adapt that talk to include the big differences from Recoil to the proposed API: hooks and context. -## Documentation +## Documentation (TODO) This would involve changes to the React documentation, mostly additions. I think this would become a major part of the suggested approach to React development. @@ -275,7 +211,7 @@ I assume the Provider pattern isn't more prominently featured because it's a pattern as opposed to an API and not a particularly popular one. I expect this being an API makes it much more suitable as a best practice. -# Unresolved questions +# Unresolved questions (TODO) - Does this conflict with lesser-known React APIs current or future? - What would need to change internally to support this? @@ -425,7 +361,6 @@ const ProductPage = ({ id }) => ( - ## Current Closest Equivalent Here's the closest I can get with our current toolset. It is debatably @@ -510,86 +445,13 @@ export const useSelectedColorState = ProductScope.hoist (() => { +## Built-In API -## Conservative Built-In APIs - -### ./provider -```tsx -import { createScope } from "react" - -export const ProductIdContext = createContext ("") -export const ProductScope = createScope () - -export const ProductProvider = ({ children, id }) => { - return ( - - - {content} - - - ) -} -``` - -### ./color/state.tsx -```tsx -import { ProductIdContext, ProductScope } from "./provider" - -const SelectedColorStore = createStore (() => { - const productId = useContext (ProductIdContext) - const [ selectedColorId, setSelectedColorId ] = useState ("") - - useEffect (() => { - setSelectedColor ("") - }, [ productId ]) - - return useMemo (() => ({ - selectedColorId, - setSelectedColorId, - }), [ selectedColorId, setSelectedColorId ]) -}, [ ProductScope ]) - -export const useSelectedColorState = () => useStore (SelectedColorStore) -``` - -### ./color/swatch.tsx -```tsx -const ColorSelectedStore = () => { - const color = useContext (ColorContext) - const { selectedColor } = useSelectedColorState () - return color === selectedColor -} - -const SelectColorStore = (color: string) => { - const { setSelectedColor } = useSelectedColorState () - return useCallback (() => { - setSelectedColor (color) - }, [ color, setSelectedColor ]) -} - -export function Swatch () { - const color = useContext () - const onClick = useSelectColor (colorId) - - const selected = useColorSelected () - // ... -} -``` - - - - -## Aggressive Built-In API - -With a built-in API we get the following benefits: -- No Scope needed, using Context gets the info we need. -- Better throwing of Errors/Promises -- We can support arguments +With a built-in API we're able to infer the level of hoisting based on +`useContext` so no Scope is needed. ### ./color/state ```tsx -import { ProductIdContext } from "./provider" - export const useSelectedColorState = hoist (() => { const productId = useContext (ProductIdContext) const [ selectedColorId, setSelectedColorId ] = useState ("") From b41765b06baf8d129390c0f7079fa11a2d7d4399 Mon Sep 17 00:00:00 2001 From: Reed Harmeyer Date: Tue, 7 Feb 2023 12:16:56 -0800 Subject: [PATCH 3/7] Update 0000-hoisting-api.md --- text/0000-hoisting-api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/text/0000-hoisting-api.md b/text/0000-hoisting-api.md index c0969aeb..a926a93d 100644 --- a/text/0000-hoisting-api.md +++ b/text/0000-hoisting-api.md @@ -165,7 +165,7 @@ The only alternative to a built-in API is a Recoil-like library. I have created one and will hopefully be able to open source it soon. Still, that isn't quite equal to having that full hooks and Context integration of a built-in API. -# Adoption strategy +# Adoption strategy (TODO) 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 @@ -187,6 +187,7 @@ 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? + --- ## Terminology (TODO) From 1bfb32030279cd934e293876f774fe376f29d90d Mon Sep 17 00:00:00 2001 From: Reed Harmeyer Date: Sat, 11 Feb 2023 00:06:40 -0800 Subject: [PATCH 4/7] Update RFC w/ memory leak --- text/0000-hoisting-api.md | 106 ++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/text/0000-hoisting-api.md b/text/0000-hoisting-api.md index a926a93d..258f7982 100644 --- a/text/0000-hoisting-api.md +++ b/text/0000-hoisting-api.md @@ -7,6 +7,8 @@ Introduce a new built-in API for creating hooks which will be automatically hoisted. This would address the desire for simplified state sharing in a more composable and flexible manner. + + # Basic example Here is a contrived example with an item in a list that can be expanded. @@ -75,15 +77,18 @@ const useExpandedStateById = hoist ((id: string) => { export const IdContext = createContext ("") export const useExpandedState = () => { - const id = useContext (TodoIdContext) + const id = useContext (IdContext) return useExpandedStateById (id) } ``` + + # Motivation -The Provider pattern is our de-facto solution to hoisted state but it has a fundamental limitation: -we cannot add Providers during the runtime without side effects caused by re-mounting the tree. +The Provider pattern is our de-facto solution to hoisted state but it has a +fundamental limitation: we cannot add Providers during the runtime without side +effects caused by re-mounting the tree. Situations where Providers are insufficient: - Lazily loading hoisted state like for Micro-Frontends or plugins. @@ -98,17 +103,10 @@ This approach has some ergonomic benefits as well: - Avoiding re-renders from derived state is trivial. - Pattern is resilient to duplication. -I think this could potentially become the industry standard over third-party libraries. - -## Backstory +I think this could potentially become the industry standard over third-party +libraries. -I was trying to figure out how to share state between components that are distributed and loaded dynamically -using Module Federation, which the Provider pattern was highly incompatible with. -I started with Recoil which worked well for global state but I also needed state that was hoisted but not global. -I ended up creating a new atomic state library one which could have state that is contextual instead of global. -After iterating on the API for a while, I realized that I was approaching hooks and -that this would be better integrated with React given it's reliance on Context. # Detailed design (TODO) @@ -122,6 +120,8 @@ defined here. Please refer to the Full Example section at the end of the RFC. + + # Drawbacks - The implementation would be non-trivial. @@ -132,85 +132,76 @@ Please refer to the Full Example section at the end of the RFC. - This is yet another state management solution. - Unsure of any conflicts their might be with lesser known features. -## Global State +## Not Global State While `hoist` (as I've described it) could be used like `useGlobalState` (which has previously been rejected) we could alter the API to prevent that. I'm not convinced that would be desirable but it is an option. -# Alternatives -What other designs have been considered? What is the impact of not doing this? ---- +# Alternatives The status quo is viable for React, this is more of an opportunity. The issues -addressed by `hoist` are some of the most prominent and +addressed by `hoist` are some of the most common in the React community and a +good number of the RFCs submitted are related to state management in one way or +another. -## Similar API +### Similar API There are a number of ways to implement a hoisting API that works in a similar way but with different syntax. `hoist` as I've described here is arguably, a bit too "auto-magical" and that could be confusing. -## Provider API +### Provider API If we had an API for making Context Providers that could be added lazily without unmounting the tree underneath that would unlock addressing some of the problems that `hoist` is addressing but it would not be close to equivalent. -## Atomic Library +### Atomic Library The only alternative to a built-in API is a Recoil-like library. I have created one and will hopefully be able to open source it soon. Still, that isn't quite equal to having that full hooks and Context integration of a built-in API. -# Adoption strategy (TODO) -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? ---- +# Adoption strategy (TODO) I think this is a similar kind of change to when hooks were added. It's a significant addition that would effect how React developers write their code but it's not a breaking change to the library and it can be adopted incrementally. -# How we teach this +It's relationship with Context could make adoption more complicated though. It +seems like a non-issue to me right now but I could be wrong. -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? +# How we teach this ---- +### Terminology (TODO) -## Terminology (TODO) +### Presentation +The first 8 minutes on this video about Recoil do a fantastic job of talking +about this problem: https://www.youtube.com/watch?v=_ISAA_Jt9kI&t=3s. We +present the (prop-less) Provider pattern and it's limitations. Then we +"extract" the Provider from the tree which would be just one of these hoisted +hooks. +We can then present it again from the "bottom-up" to show how this is another +form of composition in React as we're now explicitly importing hoisted code +instead of relying implicitly upon some Provider. -## Presentation (TODO) +### Documentation (TODO) -The first 8 minutes on this video about Recoil do a fantastic job of talking about this problem: -https://www.youtube.com/watch?v=_ISAA_Jt9kI&t=3s. -We present the (prop-less) Provider pattern and it's limitations. -Then we "extract" the Provider from the tree which would be just like a "store". -Then we present it again from the "bottom-up" -to show how this we can think about components using these stores as composition. -I would adapt that talk to include the big differences from Recoil to the proposed API: hooks and context. +This would involve changes to the React documentation, mostly additions. +I think this would become a major part of the suggested approach to React +development. I assume the Provider pattern isn't more prominently featured +because it's a pattern as opposed to an API and not a particularly popular one. -## Documentation (TODO) -This would involve changes to the React documentation, mostly additions. -I think this would become a major part of the suggested approach to React development. -I assume the Provider pattern isn't more prominently featured because it's a -pattern as opposed to an API and not a particularly popular one. -I expect this being an API makes it much more suitable as a best practice. # Unresolved questions (TODO) @@ -220,15 +211,30 @@ I expect this being an API makes it much more suitable as a best practice. - What would be needed for this to fit the React team's vision? (see: https://github.com/reactjs/rfcs/pull/130#issuecomment-901475687) +## Memory Leak +With the current version of the API, there is potential for memory leaks and +that is something that we'd need to figure out. That edge case can occur when +using hoist with args along with a context which has a value that changes. +The API can be designed differently to avoid this (by having the hoisted hooks +unmount once unused) but that comes with it's own drawbacks. Such a solution +would make the lifecycle kind of unpredictable. +### IDEA: "Fixed" Context -# Full Example +This could be solved by a variant of Context which unmounts it's children when +it's value changes. I only bring it up because I expect such a feature has +other uses like with server components. + +Additionally, with `hoist` in React, I'm having a difficult time imagining the +use case for Contexts with dynamic values. -I've added this section with the real world example that led me to this idea. +# Full Example + +I've added this section with the real world example that led me to this idea. ## Use Case From b51d2da5e0779554f939684111316edbd81d2380 Mon Sep 17 00:00:00 2001 From: Reed Harmeyer Date: Tue, 14 Feb 2023 20:48:18 -0800 Subject: [PATCH 5/7] Some minor improvements --- text/0000-hoisting-api.md | 135 ++++++++++++++++++++++++++------------ 1 file changed, 93 insertions(+), 42 deletions(-) diff --git a/text/0000-hoisting-api.md b/text/0000-hoisting-api.md index 258f7982..c5ff8025 100644 --- a/text/0000-hoisting-api.md +++ b/text/0000-hoisting-api.md @@ -4,8 +4,9 @@ # Summary -Introduce a new built-in API for creating hooks which will be automatically hoisted. -This would address the desire for simplified state sharing in a more composable and flexible manner. +Introduce a new built-in API for creating hooks which will be automatically +hoisted. This would address the desire for state sharing across components in +a more composable, explicit and flexible way. @@ -47,11 +48,11 @@ export function List ({ ids }) { } ``` -## Hoisted to Item-Level +### Hoisted to Item-Level With this version, the expanded state is shared just between the descendents of the `IdContext.Provider`. If that provider were to unmount all of the expanded -states would also be unmounted. +states would be lost. ```tsx export const IdContext = createContext ("") @@ -63,14 +64,14 @@ const useExpandedState = hoist (() => { }) ``` -## Global State +### Hoisted to Global-Level With this version, the expanded state would be sharable across all components and would persist for the length of the session. ```tsx const useExpandedStateById = hoist ((id: string) => { - useAssertConstant (id) // never + useAssertConstant (id) // always passes return useState (false) }) @@ -108,7 +109,7 @@ libraries. -# Detailed design (TODO) +# Detailed design 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 @@ -118,21 +119,88 @@ defined here. --- -Please refer to the Full Example section at the end of the RFC. +Please refer to the Full Example section at the end of the RFC for a real-world +use case that shows why `hoist` can be uniquely valuable compared to current +React and existing third-party libraries. + +## Errors & Promises + +Thrown errors and promises would be expected to route through the components +which use the hoisted hook. This is really the only viable approach otherwise +there would be huge potential unintended side effects to using `hoist`. + +### Example + +Layout should render the same with or without `hoist` here. The one difference +is if you navigate away then return `useFooterData` wouldn't suspend twice. + +```tsx +const useHeaderData = hoist (() => useAsync (async () => { + throw new Error ("No Header Data") +})) + +const useFooterData = hoist (() => useAsync (async () => { + return await loadFooterData() +})) + +function Header () { + const data = useHeaderData() + // ... +} + +function Footer () { + const data = useFooterData() + // ... +} + +function Layout ({ children }) { + return <> + }> + +
+ + + { children } + +