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()

@sebmarkbage
Copy link
Collaborator

state of a useState should not be considered stable, even if setState omitted

I'm curious why you think this is?

@josephsavona
Copy link
Contributor

state of a useState should not be considered stable, even if setState omitted

I'm curious why you think this is?

Yeah I was actually thinking about adding a compiler optimization for this case, to treat setter-less states as nonreactive.

@n-gist
Copy link
Author

n-gist commented Mar 3, 2025

@sebmarkbage, @josephsavona, well, actually yes, it could be considered stable. But I just rely on eslint-plugin-react-hooks there. If you omit setState, it doesn't start to treat state as stable
I think it is correct to optimize this case by compiler

But as an argument for 'should not be considered', I would say that, it is because the code may change. Today you consider it stable because setState is omitted, and build something dependent on that. Tomorrow you or someone else may decide that now you need setState, and the code relying on state stability may start to work wrong. Especially if such a change is buried somewhere in custom hooks

In addition, regarding optimizations, I was thinking that useMemo and useCallback could be optimized to the same as useConst works in this implementation, when their dependencies arrays are empty. But I assume that it may be already done
Or even eslint-plugin-react-hooks could suggest to use useConst, but it is just a one of maybe unnecessary ideas

@josephsavona
Copy link
Contributor

I would say that, it is because the code may change. Today you consider it stable because setState is omitted, and build something dependent on that. Tomorrow you or someone else may decide that now you need setState, and the code relying on state stability may start to work wrong. Especially if such a change is buried somewhere in custom hooks

This is a really important point. The way to think about effects is that they can re-run arbitrarily, not only when their dependencies change. That means the code in the effect has to be idempotent, just like render. So conceptually, effects always rerun, and the deps array is a convenient shortcut for idempotence and quickly bailing out of the effect if nothing has meaningfully changed.

This gets at the major challenge with useConst() — it makes it very easy to opt-out of reactivity. There are legitimate use-cases for this, but overwhelmingly what we see in practice is that people write non-idempotent effects, then try to hack the deps to be stable to get the effect to run when they want it to. Rather than make it easy to hack a value and force it to be "stable" (dropping reactivity!), our main focus is on making it easier to write idempotent effects.

@n-gist
Copy link
Author

n-gist commented Mar 4, 2025

Agreed, useConst makes this easier, but not only that. Hacking dependencies is not the purpose of the hook, and in my opinion, this is not a sufficient argument for not providing it. In my understanding, it passes this challenge already because of two simple facts. Also, its stability is not the purpose, but a nice bonus. The purpose is a store that is initialized once and available during the lifecycle of the component. It's not even meant to be used inside effects, although it can. For reactive things there is other tools. It is also not intended to be (should not be) used during rendering. It is intended to store and update the data silently that is used during other events, such as clicks or something else

But then let's also look at the problem you described, deeper, because it's interesting.
Today React is more than just a way to represent the content by composing it inside tags. Now it's a library with dozens of downloads a week, used also to build applications. And applications by nature need a wider arsenal of tools. I'm not here to wage some kind of ideological war, or to shake the architectural foundations of React. I understand that providing such a hook opens the door. All I can say is that evolution comes with paradigm shifts, or at least with the tolerance for things that do not violate established standards. In the end, it is the user's responsibility how he uses the provided tool, if it has a clear description of the correct use. Knives are not banned from sale because it is the user's responsibility to cut bread with them, or to cause harm. But the fact is that the door was always open, from the backyard. I do not call this a double standard. I actually thought that this might be more noticeable from the user's point of view than from the developer's. That's why I decided to spend time on this. Maybe it will be noticeably better, sooner or later

The facts, in my opinion, of overcoming the challenge:

  1. Before functional components, there were classes, and they could use fields, and it was the same thing. Were there any serious problems with that? (I don't know, I wasn't here then)
  2. Just look at the documentation of useRef, how complicated it is. All those Caveats, Pitfalls and lazy initialization, and it all concerns current, because that's what's holding the door open. That's the page where the possibility of a hack is clearly shown to the user. useConst just brings it to the surface and presents it unashamedly, in a more correct and proper way. Of course, its documentation should contain some of these caveats too. But useConst even more safe in compare of useRef().current, because it is not mutable

For fun, as a counter-attack to the protection of idempotency concept, I suggest you hide current. And so that useRef can still be used to get a reference to an element, instead provide a getter that will pass it along, retrieving it from a secret storage. You can imagine how many sites and applications will be broken, deprived of the 'hack' they use today.
This is not required, of course. But in my rough opinion, all cases where useRef is used, except for getting a reference to an element, actually need 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.

4 participants