react: Bug: `useId()` not working inside ``
useId() doesn’t return a stable ID when used inside <Suspense>.
function App() {
return (
<React.Suspense fallback={<p>I'm lazy loaded...</p>}>
<LazyComponent />
</React.Suspense>
)
}
function LazyComponent() {
const id = useId()
console.log('id:', id)
// The usual `throw promise` technique
// ...
}
Expected behavior: The logged ID id: :r0: to stay :r0: (i.e. the ID is stable).
Current behavior: The logged ID changes: id: :r0:, then id: :r1:, then id: :r2:, etc.
React version: 18.1.0.
Reproduction
See github.com/brillout/react-bug-useId-suspense.
CodeSandbox: https://codesandbox.io/s/github/brillout/react-bug-useId-suspense?file=/index.tsx
Additional Context
This is a blocker for useAsync, Telefunc and probably many other React tools.
About this issue
- Original URL
- State: open
- Created 2 years ago
- Reactions: 11
- Comments: 24 (8 by maintainers)
The way I read the documentation for
useIdis that the ID should stay stable for the same position in the parent component tree. If a component suspends and has to be re-rendered later, that does not change its position in the parent tree and hence theuseIdoutput should stay the same as well. Deviating from that does sound like a bug.And yes, this would be an incredibly useful behaviour for libraries that need ad-hoc cache keys for fetching, processing, etc. For example, the @pmndrs/react-three-lightmap library computes the lightmap based on passed-in scene content which is a ReactNode. Right now to suspend while computation happens I will need to ask the developer for an arbitrary cache key string. Relying on
useIdwould conveniently produce an implicit key for me instead.@vkurchatkin That’s besides the point: React does internally know that it’s the same React element and, as a user, I’d expect React to provide the same
const id = useId()since it’s the same React element.I believe the mental model here to be “same id” <=> “same React element”.
That’s why I’d say it’s a bug and not a feature request.
Besides terminology, being able to use
useId()with<Suspense>would be very convenient.For me, it’s actually the number 1 use case for
useId(). (But I’m probably missing other use cases foruseId().)And, concretely, it’s a blocker for the libraries I’m developing. For example:
But this is currently not possible because
useId()doesn’t work with<Suspsense>.I think this issue goes beyond stabilizing the id across suspension on client-side updates. This also affects SSR and client hydration. I recently ran into an issue where the id generated on the server was different than the id generated on the client which ultimately caused hydration issues.
Let’s say we have an SSR application that fetches data using some modern “fetch-as-you-render” library to load data from an external service. Now when rendering this on the server, the upper suspense boundary is going to suspend on the server and once the request resolved react fizz streams the html to the client. Now for the client we don’t want to repeat the same request so instead, the server is injecting a small
<script>...</script>snippet that will add the services response to some global “cache”. Rendering now on the client means that we’d simply not suspend and just read from that global “cache”. All good but this means now that our server is suspending once while the client is not, which (I think) then causes the same problem.I’ve created a small reproducible example: https://codesandbox.io/s/react-use-id-unstable-37kndi?file=/src/App.js
Running this will cause some hydration error because of the id, and in this case it is showing the use case for accessibility attributes as advertised in react docs and not “just” cache keys
Could someone confirm if this is actually the case? Given that Suspense-aware hooks need an external cache (not to be confused with the cache keys mentioned here; alluded to in #28588), it was my understanding that when a component suspends by throwing a Promise, that actually causes the tree up to the nearest
<Suspense>boundary to unmount, losing the states of all hooks in scope. React then decides (sometime later) to attempt to re-render the component, which may succeed if no Promise/Error is thrown during that attempt.If that is the case, then
useId()losing its state would make sense in the context of Suspense, even if that isn’t the ideal behavior. I’d appreciate if the Suspense docs were at least updated to clarify this, because if my understanding is correct, this is not intuitive behavior.I haven’t checked with the team, but I doubt that the intention of the API is for it to be reliable as a cache key. I think the expectation is more “it’s guaranteed to not clash” and “it’s guaranteed to remain stable while a component is mounted”, not “it’s guaranteed to stay stable between attempts to mount”.
@vkurchatkin
useIdis explicitly documented to work as others are expecting it to:From https://react.dev/reference/react/useId#why-is-useid-better-than-an-incrementing-counter
This implies that as long as the parent path is the same, the first use of
useIdin a render function should always generate the same ID.I guess arguing whether it is or isn’t idiomatic is beside the point. The actual point is:
useIdnever worked the way you want it work nor it says anywhere in the documentation that it should work like this.This seems to be completely expected. When component suspends during initial render it is essentially not even mounted so it can’t store any associated state, that is
useState,useRef, etc.useIdis not an exception