[React 19] Dynamically importing a data fetching hook via `use()` leads to an error
dahaca opened this issue ยท 9 comments
Describe the bug
I've been trying to create a component which would encapsulate data loading logic, to have the most granular approach to using <Suspense/>
, avoiding UI duplication with skeletons. My goal was to have one component which would handle suspending, fallback UI, loading, polling, errors, etc, and be used in a server component like so:
<AsyncValue
query={useSomeQuery} // query wrapper fn or potentially a query wrapper fn name string
queryProps={{
itemId: "0001", // optional query props
}}
dataKey="name" // optional accessor to a value which `extends ReactNode` in case an object is returned
render={Value} // Some valid React compoennt
/>
This, afaik, would result in everything but the suspended value being pre-rendered server-side, eliminating the need to care about large skeletons, duplicating layout components or what not. Thought that would be neat?
I have arrived at a working solution, but a hacky one, as it used throw
to trigger <Suspense />
. When it struck me that we now have the use()
hook to handle exactly what I thought was needed โ keeping a component suspended while dynamically importing a hook. However, when I tried using it I saw:
Error: Update hook called on initial render. This is likely a bug in React. Please file an issue.
Even though it seems to function. The data fetch is fired on the server, response is streamed to the client, where the hook proceeds to poll and update the value.
TLDR:
- Is dynamically importing hooks even viable or it ruins some logic under the hood?
- If is is viable, can it be done via a
use()
hook to trigger<Suspense />
boundaries?
Reproducible example
https://codesandbox.io/p/devbox/6xmt4y
Expected behavior
Work with use()
like it does without it.
React version
19.0.0-rc-2d16326d-20240930
Related issue
Hi @dahaca,
Thanks for bringing up this interesting use case! I wanted to share some thoughts and ask a few clarifying questions.
Observations:
Error Details:
The "Update hook called on initial render" error typically occurs when hooks are used in a way that violates React's expectations for their static call order. Dynamic imports of hooks might interfere with this, as React relies on the hook structure being consistent across renders.
Itโs possible that dynamically importing useSuspenseQuery is creating a mismatch in hook behavior during the streaming process.
Current Behavior:
It's fascinating that the current setup still functions despite the error. This suggests that while React detects an issue, it might not fully disrupt the lifecycle. However, relying on this behavior might be unstable in production.
Thoughts:
Dynamic Imports with use():
While use() is experimental and promising for handling server-side async data, its behavior with hooks isn't well-documented yet. Hook imports might break assumptions about React's render pipeline, especially during hydration.
It may be more stable to handle the dynamic logic outside the hook itself, wrapping it in a utility function to align better with React's expectations.
Alternative Approach:
Instead of importing the hook dynamically, would it work to dynamically load the query logic and use a static wrapper for the hook? For example:
async function fetchDynamicQuery(query, queryProps) {
const { default: queryLogic } = await query();
return queryLogic(queryProps);
}
Then call this in your component wrapped with use().
Can you reduce the example to the minimal required code to reproduce this without all the abstractions you currently have in place? It's hard to follow what's going on.
@eps1lon Thanks for the reply! And apologies, I am aware the whole set up is quite react-query
specific and indeed not easy to follow while ignoring library semantics ๐ I have simplified the code a bit. Let me try to clear things up too and if that won't be enough, I will try to modify the example further!
The most crucial and relevant-to-the-issue code is in useDynamicQuery
โ that's where the dynamic import of hooks happens. You can also import useDynamicQuery
from useDynamicQuery_hacky.ts to observe that the error is not popping up when use()
is not, well, in use, and a thrown promise triggers <Suspense />
instead.
<QueriedValue />
is the "use client"
part of <AsyncValue />
rendering a value returned from a dynamically imported data fetching react-query
hook. While <AsyncValue />
itself renders the <Suspense />
boundary, a fallback and <p />
to house a value from <QueriedValue />
.
I hope that made the setup a bit easier to understand! :)
I don't think this is minimal. Remove as much code as possible until it no longer reproduces. It doesn't need to be code you would actually ship to production. Just the code that highlights the issue. We have to go through many issues and dissecting each one takes a lot of time.
I'm seeing an import
within the hook though:
const query = useDynamicQuery(
import(`~/queries/${queryName}`).then(
That means use
will eventually see an uncached Promise which is not supported. The Promise must be cached somewhere so that a re-render sees the same Promise.
Oh wow, seems to be it! ๐ฎ
I have simplified the useDynamicQuery()
hook to accept the query name only and then added useMemo()
and a . Added promiseCache
on top of the moduleCache
useDynamicQuery_fixed.ts
to the updated sandbox and duplicating it here.
Edit: just leaving useMemo()
without the promiseCache
seems to heal the error too.
"use client";
import { use, useMemo } from "react";
const moduleCache: Record<string, unknown> = {};
type QueryModule = Record<string, unknown>;
export const useDynamicQuery = (queryName: string) => {
// Memoize the promise creation to ensure stability across renders
const promise = useMemo(() => {
// Return cached module if available
if (moduleCache[queryName]) {
return Promise.resolve(moduleCache[queryName]);
}
// Create new promise for this query
const newPromise = import(`~/queries/${queryName}`).then(
(mod: QueryModule) => {
const queryModule = mod[queryName];
if (!queryModule) {
throw new Error(`Query ${queryName} not found in module`);
}
moduleCache[queryName] = queryModule;
return queryModule;
}
);
return newPromise;
}, [queryName]);
return use(promise);
};
Nonetheless, could you confirm that importing hooks dynamically in tandem with use()
like that is a "legal" React way? Haven't seen it being done before but seems to be a nice DX boost in some cases, use()
is awesome!
Nonetheless, could you confirm that importing hooks dynamically in tandem with use() like that is a "legal" React way?
I don't think that's a problem. use()
should be able to unwrap any Promise as long as it is cached.
Just keep the usual Rules of Hooks in mind i.e. you can't import Hooks, unwrap them with use
and call these Hooks within the same Component since that would mean you called Hooks dynamically.
useMemo
is not sufficient to cache the Promise. It's only a performance optimisation not a semantic guarantee. You need another way to cache and cleanup the Promise. I'd use useQuery
for that instead.
Gotcha!
Do I understand correctly that, in the approach outlined here, since use()
is given a promise within another hook the rules of hooks are not broken?
And finally, now when you've mentioned useQuery
, seems like the easiest way to achieve what I am trying to is to actually resort to useSuspenseQuery
since it handles caching well and triggers <Suspense />
๐ค Is this aligned with what you've meant by saying you'd use useQuery
or you had something else in mind? Would be very grateful for a final word on the issue from you!
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
type QueryModule = Record<string, unknown>;
async function loadQuery(queryName: string) {
const mod = (await import(`~/queries/${queryName}`)) as QueryModule;
const queryModule = mod[queryName];
if (!queryModule) {
throw new Error(`Query ${queryName} not found in module`);
}
return queryModule;
}
export const useDynamicQuery = (queryName: string) => {
const { data } = useSuspenseQuery({
queryKey: ["dynamicQuery", queryName],
queryFn: () => loadQuery(queryName),
});
return data;
};
All in all, other than those 2 things I got all the answers I needed, huge thanks! ๐