redux-toolkit
redux-toolkit copied to clipboard
I would like to know the plan for React Suspense with RTK Query 2.0
https://github.com/reduxjs/redux-toolkit/discussions/2797#discussioncomment-4396073 I prefer RTK. However, I would also like to use Suspense. Will Suspense be supported in RTK 2.0? I couldn't find any issues etc. that mention it.
Suspense for data fetching is still an experimental feature, and there are known issues with it that take a lot of working around, like supporting streaming SSR (which, going forward, will be the default if you only have a single React Server Component).
Every code we write right now will be superseded by the React features (insertIntoStream and use with observable support) that are not there yet, and will result in multiple incompatible api changes.
I'm warming up to the fact that we'll have to do that, but I'm still watching the space a little bit.
Is there any way of doing a stop-gap in a non-bespoke manner, e.g. via a middleware?
That might allow those RTK users that are looking for Suspense-support to opt in to having it working for useQuery as well as the hooks generated based on endpoint names, without needing to manually glue things together.
Userland 'wakeables' are (currently...) pretty much just a stable promise from which you record the value and reason; and if you try to access either before the promise has settled, you throw the promise instead. E.g.
type PromiseState = "pending" | "fulfilled" | "rejected";
const createWakeable = <Value, Error>(promise: Promise<Value>) => {
let state: PromiseState = "pending";
let reason: Error;
let value: Value;
promise
.then(
v => { value = v; state = "fulfilled"; },
r => { reason = r; state = "rejected"; }
)
.catch(() => { /* swallow uncaught exceptions from promise */ })
return Object.freeze({
get state() { return state; },
get reason() {
if (state === "pending") throw promise;
return reason;
},
get value() {
if (state === "pending") throw promise
return value;
}
});
};
The only thing a narrow-band implementation needs is a way to get hold of that promise.
One that doesn't involve handwriting it at every turn, as is currently required because you need to call api.util.getRunningQueryThunk for it, and the api is not part of what useQuery or its generated typed hooks return. Their data contains only the endpointName meaning one can't simply create a basic wrapper function for it. One needs to hand-associate the api to the hook every time.
Come to think of it:
If we could just get the api into the QueryResult return type, that would even be enough to connect the dots in a passable way.
Suspense may be problematic for SSR, but for the (probably quite sizeable) portion of users that doesn't use SSR, that literally doesn't matter. They could roll their own minimal hook decorator easily - if they dare - as long as all the required parts are holistically there.
Dunno how the dots would get connected, but Brian Vaughn has extracted a bunch of his Suspense cache utils from the Replay codebase into a standalone package at https://suspense.vercel.app/ . Might be worth looking at and seeing if there's a way to tie things together.
@markerikson
Those tools primarily work off of a cache created with createCache.
It takes an async function - or rather: anything returning a promise.
The very promise that is hard to pull out in a generic manner with RTK Query.
RTK Query already does all the caching. And creating a stable, reproducible 'wakeable' from a promise that interfaces with React Suspense isn't really the problem either. (See above code snippit.) Such wakeables can also trivially be reassociated with their original promises through a WeakMap. None of that is really a problem.
The only real problem for the minimal use case is getting hold of that promise in a generic way that doesn't involve handwriting. Sure; we could manually write decorator functions that preassociate the promise with each and every query result, so we don't have to do it bespoke with every use of the querying hooks. But that's in fact not much better than just doing it bespoke. As you're still stuck writing (and maintaining!) a mountain of wrapper code if your APIs are big.
@rjgotten : what actual end-user API are you visualizing here? How would this be accessed via useQuery() ?
The most basic thing possible is:
const { promise } = useQuery( ... );
In that case it wouldn't even need to be a stable promise reference, because one could just in-situ decide that:
const { promise, isLoading } = useQuery( ... );
if (isLoading) throw promise;
As soon as the promise resolves, we would expect RTK Query's work to also be done, and thus isLoading would be false and we wouldn't hit any problem cases with infinite loops.
A nicer version of this would put a wakeable wrapping similar to what I snippited above, around that promise and surface that as part of the contract. E.g.
// Will throw if pending because directly accesses value and error while destructuring.
const { status, value, error } = useQuery( ... ).wakeable();
// More conservative, will throw and suspend as soon as accessing either value or error.
// Allows inspecting `wakeable.status` to know if accessing either valur or error is 'safe'.
// Might be useful for some cases.
const wakeable = useQuery( ... ).wakeable();
Might even extend that to:
// Won't report existing value when a refetch is in progress.
// Will hit suspense again and show fallback / loader UI.
const { status, value, error } = useQuery( ... ).wakeable({ fetching : true });
// Will report existing value when a refetch is in progress.
// Won't hit suspense again.
const { status, value, error } = useQuery( ... ).wakeable({ fetching : false });
The only tricky thing with it might be the managing of cache subscriptions.
I.e. as soon as a <Suspense> boundary is hit and renders a fallback, the original component - which has the cache subscription - is unmounted and loses the subscription, afaict. But if QueryResult would incorporate an actual wakeable() wrapper itself, then it could internally register its own subscription to keep the query alive until 'unsuspended'.
Albeit; on a best effort basis - as the general case of that is intractable:
One component cannot know the suspension state of a sibling under the same <Suspense> boundary.
Thus component A could unmount and lose its cache subscription when its sibling B wants to suspend on a query, and vice-versa. They could theoretically indefinitely ping-pong between those two states in pessimistic cases.
We put together a useSuspenseQuery hook that seems to be working, using useLazyQuery and useQuery together.
TBH I did not understand most of the issues described above in this thread, and this code has not been tested very thoroughly yet, so I'm curious to know if others can call out issues with this approach.
js:
function useQueryHooks({ api, endpoint, params }) {
const endpointDefinition = api.endpoints[endpoint];
const [trigger] = endpointDefinition.useLazyQuery();
const { data, isError, isLoading, error, isFetching } = endpointDefinition.useQuery(params);
return {
trigger,
data,
isError,
error,
isLoading,
isFetching,
};
}
export function useSuspenseQuery(api, endpoint, params) {
const { trigger, data, isError, error, isLoading, isFetching } = useQueryHooks({ api, endpoint, params });
if (isLoading) {
const loadingPromise = trigger(params);
throw loadingPromise;
}
if (isError) throw error;
return {
data,
isFetching,
refetch: () => trigger(params),
};
}
typescript:
import { type QueryArgFrom, type EndpointDefinitions } from '@reduxjs/toolkit/dist/query/endpointDefinitions';
import {
type Api,
type BaseQueryFn,
type FetchArgs,
type FetchBaseQueryError,
} from '@reduxjs/toolkit/dist/query/react';
import { type AnyObject } from '@Types';
type ApiResultType<
BaseQuery extends BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError>,
Definitions extends EndpointDefinitions,
Path extends string,
TagTypes extends string,
Name extends keyof Api<BaseQuery, Definitions, Path, TagTypes>['endpoints'],
> = Api<BaseQuery, Definitions, Path, TagTypes>['endpoints'][Name]['Types']['ResultType'];
type QueryResult<
BaseQuery extends BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError>,
Definitions extends EndpointDefinitions,
Path extends string,
TagTypes extends string,
Name extends keyof Api<BaseQuery, Definitions, Path, TagTypes>['endpoints'],
> = {
data: ApiResultType<BaseQuery, Definitions, Path, TagTypes, Name>;
isFetching: boolean;
refetch: () => void;
};
export function useQueryHooks<
BaseQuery extends BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError>,
Definitions extends EndpointDefinitions,
Path extends string,
TagTypes extends string,
Name extends keyof Api<BaseQuery, Definitions, Path, TagTypes>['endpoints'],
Params extends QueryArgFrom<Definitions[Name]>,
>({ api, endpoint, params }: { api: Api<BaseQuery, Definitions, Path, TagTypes>; endpoint: Name; params?: Params }) {
type ResultType = ApiResultType<BaseQuery, Definitions, Path, TagTypes, Name>;
const endpointDefinition = api.endpoints[endpoint];
// FIXME: it would be better if we didn't have to cast these types ourselves. There's gotta be a better way
const useLazyQuery = (endpointDefinition as unknown as AnyObject).useLazyQuery as unknown as () => [
(params?: Params) => Promise<ResultType>,
];
const useQuery = (endpointDefinition as unknown as AnyObject).useQuery as unknown as (
params?: Params,
) => ResultType;
const [trigger] = useLazyQuery();
const { data, isError, isLoading, error, isFetching } = useQuery(params);
return {
trigger,
data,
isError,
error,
isLoading,
isFetching,
};
}
export function useSuspenseQuery<
BaseQuery extends BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError>,
Definitions extends EndpointDefinitions,
Path extends string,
TagTypes extends string,
Name extends keyof Api<BaseQuery, Definitions, Path, TagTypes>['endpoints'],
Params extends QueryArgFrom<Definitions[Name]>,
>(
api: Api<BaseQuery, Definitions, Path, TagTypes>,
endpoint: Name,
params?: Params,
): QueryResult<BaseQuery, Definitions, Path, TagTypes, Name> {
const { trigger, data, isError, error, isLoading, isFetching } = useQueryHooks({ api, endpoint, params });
if (isLoading) {
const loadingPromise = trigger(params);
throw loadingPromise;
}
if (isError) throw error;
return {
data,
// is fetching is true when there is already data, but we are fetching new data
isFetching,
refetch: () => trigger(params),
};
}
looks like react suspense is no longer experimental https://react.dev/reference/react/Suspense
@searleser97 Suspense itself, yes. But there are no officially well-supported patterns for "Suspense for Client-Side Data Fetching".
Every solution that's currently out there is incredibly complex as React is still missing some native primitives to fully support that use case.
@phryneas React is still missing some native primitives to fully support that use case.
? The use hook is also no longer experimental. It reads a promise value; throws if said promise is rejected; and goes into Suspense when it's still pending.
What more does React need to provide? Ensuring the promise itself remains a stable identity outside of the lifecycle of the component, is never going to be supported by React, because that's not part of the design. The component unmounts and thus its state is purged. It has to, because the nearest Suspense boundary could in theory be miles up the component tree.
Any querying framework will by design have to be responsible for maintaining its own stable promise.
I mean... possibly they could provide a wrapper 'exotic' component implementation for performance optimalizations that doesn't completely purge the suspended component tree, but somehow cache it -- similar to how exotic component wrappers like memo change things wrt component tree updates for situationally better performance. But you'd still never be able to assume those are used consistently. (Nor would or should they apply to every possible use case.)
@rjgotten What comes to mind:
-
A way to identify if a suspended component ever mounted or if the suspense was cancelled and data needs to be cleaned up.
Currently, there is no way to track this (the component never fully mounts,useEffectetc. don't take effect, so there is also no cleanup) and all libraries supporting suspense make do with weird timer workarounds, discarding data if a component doesn't mount fast enough, to get around the memory leaks ensuing from this. (I believe there is a talk recording on youtube from someone on the Relay team on this from back in the day, everything said there is still true.) They essentially force you to do a side effect during render, which is forbidden by React, without cleaning up after it. -
A way to update values passed to
usebeyond the first Promise.
The frontend does not live for a single render. There is no good way to do this in a tearing-safe way apart from using something likeuseSyncExternalStoreto create a new promise - with the drawback that a new promise will now also flush the whole UI, becauseuSESis essentially the anti-suspsense hook. This might be solves by allowing observables or somehing similar inuse, which is currently not supported. (Also, React might just discard that new promise because they try to keep identity stable and sometimes use caches promises, not the newest one - with no way of distinguishing if it's not a different one) -
A way to communicate data from server-rendered suspending client components to the browser without invoking framework-specific workarounds because React doesn't provide the primitives.
Without that you have to double-execute all network requests on server and client and cross fingers that they get the same response or you have a hydration mismatch.
Believe me, there is enough missing in the picture here, and I'm already maintaining one suspense implementation at Apollo Client, as well as 4 framework-specific workarounds packages for the SSR-of-client-components case. All of this is far from ideal and consumes a lot of time. As it currently stands, this is not well enough supported to maintain another of those implementations in my spare time.