Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useConst() #32490

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

useConst() #32490

wants to merge 1 commit into from

Conversation

n-gist
Copy link

@n-gist n-gist commented Feb 28, 2025

useConst()


Stable. Performant. Simple.
The missing brick of stability

const stable = useConst(constFactory: () => T) : T

  • Gist implementation
  • Approve/change of workaround for react-server
  • eslint-plugin-react-hooks - result stability
  • eslint-plugin-react-hooks - constFactory scope
  • Tests
  • @types/react
  • Documentation
  • Support DevTools editable values
  • React Compiler

Local stable storage associated with the component's lifecycle is needed quite often. Typically, useRef/useState used for this. However, there are cases when this is not practical

  • useState runs dead code if setState is not going to be used
  • state of a useState should not be considered stable, even if setState omitted
  • creation of objects, to pass to useRef, inside render, results in all but first object to be garbage
  • lazy assignment to ref.current after ref = useRef() to avoid garbage produces boilerplate code
  • { current: T } box is unnecessary

This is solved by using a custom hook that utilizes useRef and returns the current property
There are also a number of libraries that do this

Examples
// chakra-ui/chakra-ui
// packages/react/src/hooks/use-const.ts

export function useConst<T extends any>(init: T | InitFn<T>): T {
  const ref = useRef(null)
  if (ref.current === null) {
    // @ts-ignore
    ref.current = typeof init === "function" ? init() : init
  }
  return ref.current as T
}
// microsoft/fluentui
// packages/react-hooks/src/useConst.ts

export function useConst<T>(initialValue: T | (() => T)): T {
  // Use useRef to store the value because it's the least expensive built-in hook that works here
  // (we could also use `const [value] = React.useState(initialValue)` but that's more expensive
  // internally due to reducer handling which we don't need)
  const ref = React.useRef<{ value: T }>();
  if (ref.current === undefined) {
    // Box the value in an object so we can tell if it's initialized even if the initializer
    // returns/is undefined
    ref.current = {
      value: typeof initialValue === 'function' ? (initialValue as Function)() : initialValue,
    };
  }
  return ref.current.value;
}

Yup, it has to run an excessive check, and even box the value second time, just because of no access to hooks dispatchers, to separate a hook living phases. It looks like creating a simple thing by complicating a more complex tool designed for different thing.
This is acceptable, as it gives the desired result, and as the only available option. But then eslint starts to warn about the need to add references to other hooks dependencies.
Here are some of the issues related to that: #16873 #19125 #20205 #20477 #20752
Arguments for not allowing to specify in the eslint-plugin-react-hooks settings which custom hooks should be considered stable seem convincing.

It feels like something is just missing. The absence of it pushes to create workarounds and the need of workarounds to close issues produced by that. Something that should have been there from the start and revealed along with useRef and useState. So simple, that useRef and useState could have been inherited from, if there was such a need. And here is where one may give up and specify references. Or try to make it right

reader.isFromTeam(React) ? review(PR.changes) : action(mood as 'comment' | 'reaction')

@n-gist
Copy link
Author

n-gist commented Feb 28, 2025

Regarding open questions for discussion or changes, I would like to outline my vision that guided me

// why
useConst(constFactory: () => T) : T
// and not
useConst(initialValue: (() => T) | T) : T

Allowing to pass anything other than factory function violates hook design and compromises its goals. Though internally this would cost just one check during hook mount phase, this indirectly encourages the user to create objects, to pass, during a render, creating garbage objects by that, which defeats the purpose of the hook to be performant

However, there may be a case when one would want to capture a props-dependent primitive value during first render pass. It would be convenient to just pass it as an argument. But allowing to pass primitives, but not objects, already starts to compromise the hook to be simple and straightforward. Also, this case should probably be considered special, given the nature of the React Component. Moreover, if there is a need to capture more than one primitive, it is more likely that a function closure will (should) be used, to create object with several properties, rather than a multiple uses of a hook. So, allowing primitives to be passed, to satisfy only a special use case seems irrational

@n-gist
Copy link
Author

n-gist commented Feb 28, 2025

constFactory() scope

Although creating closures negligible for performance impacts, it still produces garbage in case if closure is used only by function passed to hook. User should not be prohibited from using it, but the keeping performance as one of the hook's goals dictates that this should be given additional attention

constFactory() is meant to be declared outside of the component function. To preserve hook's performance purpose in cases of capturing props or props-dependent values, documentation could have an example of a proper usage, to avoid creation of closures, like this one

const constFactory = () => ({
  captured: false,
  capturedValue: null
})

const Component = (props) => {
  const stable = useConst(constFactory)
  if (!stable.captured) {
    stable.capturedValue = props.valueToCapture
    stable.captured = true
  }
}

To increase attention to this, eslint-plugin-react-hooks could warn when constFactory() declared inside of a component. Rule could be on or off by default, but having it ready would be nice

@n-gist
Copy link
Author

n-gist commented Feb 28, 2025

react-server side

I didn't dig deep enough into it, but if I understood it right, there is no concept of hooks dispatchers in server components (yet?). Thus there is no clear separation of a first and subsequent renderings. So I used constant instance of Symbol() to mark hook state as initialized in case when constFactory() returns null. This should be verified with a higher level of competence in the operation of react-server, if memoizedState could be accessed directly by other entities, bypassing ReactFizzHooks.js's useConst()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants