-
Notifications
You must be signed in to change notification settings - Fork 7
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
react-apollo useQuery: add support for Suspense #162
Comments
I think this is a very important feature to have. I wonder why it hasn't attracted that much attention yet... I also came from |
Agree. Actually I think it's pretty easy to implement it yourself by wrapping |
@jfrolich do you mind sharing your implementation on |
I don't use suspense for |
It's a bit more of just throwing the promise, since the current I removed everything that is linked to lazy queries, it was easier to work on it like this. Taken from https://github.com/apollographql/react-apollo/blob/master/packages/hooks/src/utils/useBaseQuery.ts import {useQuery as useApolloQuery} from '@apollo/react-hooks';
import {getApolloContext} from '@apollo/react-common';
import {QueryData} from '@apollo/react-hooks/lib/data/QueryData';
import {useDeepMemo} from '@apollo/react-hooks/lib/utils/useDeepMemo';
import {useContext, useEffect, useReducer} from 'react';
+ // we need to store all queries references somewhere
+ // since the refs are dropped when throwing the promise
+ // this lives in the module for now but could be stored in the context, I guess
+ const queryDataRefs = new Map();
const forceUpdateRefs = new Map();
export default function useBaseQuery(query, options) {
+ // Oops, conditional hook, but let's use the real useQuery if suspense is not needed
+ /* eslint-disable */
+ if (options && !options.suspend) {
+ return useApolloQuery(query, options);
+ }
+ /* eslint-enable */
const context = useContext(getApolloContext());
- const [tick, forceUpdate] = useReducer(x => x + 1);
+ const [tick, forceUpdate] = useState(0);
const updatedOptions = options
? {...options, query}
: {query};
- const queryDataRef = useRef();
+ // we used the serialized options to keep track of the queries
+ const queryKey = JSON.stringify(updatedOptions);
+
+ if (!queryDataRefs.has(queryKey)) {
+ queryDataRefs.set(
+ queryKey,
+ new QueryData({
+ options: updatedOptions,
+ context,
+ forceUpdate: () => {
+ const snapshotOfForceUpdate = Array.from(
+ forceUpdateRefs.values(),
+ );
+
+ snapshotOfForceUpdate.forEach((fn) => {
+ forceUpdateRefs.delete(fn);
+ fn(x => x + 1);
+ });
+ },
+ }),
+ );
+ }
- const queryData = queryDataRef.current;
+ const queryData = queryDataRefs.get(queryKey);
queryData.setOptions(updatedOptions);
queryData.context = context;
+ // I had a hard time with this one
+ // if you have several parent-children components that triggers the same query,
+ // we want to keep track of all the hooks we have to forceUpdate
+ if (!forceUpdateRefs.has(forceUpdate)) {
+ forceUpdateRefs.set(forceUpdate, forceUpdate);
+ }
// `onError` and `onCompleted` callback functions will not always have a
// stable identity, so we'll exclude them from the memoization key to
// prevent `afterExecute` from being triggered un-necessarily.
const memo = {
options: Object.assign(Object.assign({}, updatedOptions), {
onError: undefined,
onCompleted: undefined,
}),
context,
tick,
};
const result = useDeepMemo(() => queryData.execute(), memo);
const queryResult = result;
useEffect(() => queryData.afterExecute({lazy: false}), [
queryResult.loading,
queryResult.networkStatus,
queryResult.error,
queryResult.data,
]);
useEffect(
() => () => {
queryData.cleanup();
+ // I have to admit, I don't really know for that one
+ // but better play it safe
+ queryDataRefs.delete(queryKey);
+ forceUpdateRefs.delete(forceUpdate);
},
[],
);
+ if (options && options.suspend && result.loading) {
+ forceUpdateRefs.delete(forceUpdate);
+ throw result.observable.result();
+ }
return result;
} If anyone is willing to update this piece of code, make it easier or more robust, I'll be grateful. :) EDIT: I updated the code, we had some trouble with it. It should work better this way. |
React released Concurrent Mode (Experimental) with suspense So since we are both curious people and library authors this might be the best time to explore how we can use suspense with apollo 🚀 https://reactjs.org/docs/concurrent-mode-suspense.html#for-library-authors |
Glad to see this issue already exists and thanks for the amazing work on Apollo! I'm almost completely out of my element here, but wanted to mention: perhaps it would be interesting to infer a |
I'm on pins and needles to see how the Apollo team integrates suspense! The benefits, like useTransition support while you wait for an Apollo-powered page to load, will be massive. |
Apollo Client 3 will have React Suspense support. The level of this support is still a bit up in the air, as we don't want to tie the release of AC 3 to React's release schedule, and Suspense for data fetching is still experimental. At a minimum though, all Suspense functionality in the |
Really appreciate looking at suspense for data fetching, we've been using
|
Thank you so much for mentioning that @rajington, I had no idea there was an experimental implementation. |
I wonder what are strategies to handle enforced The only thing that comes to my mind is to have that query for a second time there with suspense disabled and that fetchPolicy to keep cache up-to-date. It feels weird but cannot think of a better solution. |
@alflennik just FYI react-apollo-hooks was the unoffical hooks before Apollo's, so it's nonstandard in other ways as well, like you have to wrap it with another provider @FredyC |
@rajington No my point is how to keep data up-to-date if we cannot use other than |
What really intrigues me is how the fetch as you render approach will be accomplished. 🤯 |
I don't think that's actually a concern of the Apollo. Have a look at a really interesting discussion over at react-router |
Anyone knows if there is already an experimental build of Apollo with suspense. I'd like to test it. |
@hwillson Does the above still hold true? I looked at v3 beta docs https://www.apollographql.com/docs/react/v3.0-beta/data/queries/ and it does not mention suspense. |
Apollo Client's suspense work has been started, but we've had to put it on-hold for a bit due to time/resource constraints. Suspense support isn't going to make it into the AC 3.0 release unfortunately, but the good news is we've just hired more AC team members, one of which is going to start by getting suspense support in place right after AC 3 launches. Right now our focus is on paying down a lot of tech debt in Apollo Client as we nail down the new cache structure and API. A lot of this has to be done to effectively support suspense, but more immediately it has to be done to help get rid of outstanding Apollo Client bugs. We're streamlining the internals to make them much more dependable and the changes we have coming will eradicate a large amount of the open issues we currently have. |
Until Apollo will release official useSuspenseQuery hook I've created own hook to manage this and collaborate with Apollo Client, you can take it here: https://www.npmjs.com/package/@brainly/use-suspense-query |
Any update on when the support of Suspense will be available now that AC 3 was released ? |
Our Suspense plans are on-hold right now due to the fact that React concurrent mode is still only available via an experimental build, and we're not sure when that's going to change. We've decided to invest our time in other areas right now, but will definitely be diving back into all of this when it looks like concurrent mode will be available in a stable release. |
@hwillson I understand that it is experimental, but it is been experimental since forever and alternatives such as urql, swr and react-query all support it if I'm not mistaken. At this point is very unlikely that the api will change drastically in the near future. |
Suspense has been supported by urql for about 4 months now (https://github.com/FormidableLabs/urql/tree/main/exchanges/suspense). I really think apollo has significally changed the GraphQL client game, but I'm sad they became that library that kept on changing API and package organization without being able to adapt to new react features. I remember that it took quite a while until hooks were supported in the official apollo react core. And seeing how long it takes (@brielov has a great point about the API probably not changing) doesn't really show the innovative spirit that once drove apollo. It doesn't really matter, I've switched to urql already, but I still hope that apollo catches up again in terms of feature parity and speed, its been my reliable und trusted gql client for years; and they still offer tremendous libraries and toolsets that help devs work with graphQL. |
Is suspense now supported in the new Apollo Client 3? |
Yes, concurrent mode is still experimental but Suspense is widely used in production with React.Lazy and ErrorBoundary paradigm : https://reactjs.org/docs/code-splitting.html#reactlazy |
Yes, Suspense for Code Splitting is in production and widely used. But Suspense for Data Fetching (the kind of Suspense that Apollo would be doing) is very much part of Concurrent Mode. Data fetching suspense proper is only available in |
For projects starting from scratch or starting a major refactoring (e.g. adopting GraphQL), leveraging Suspense patterns significantly changes how components are written, the amount of boilerplate and 3rd-party libs, and even how other patterns like SSR are handled. We're currently stuck on AC2 ( I have no idea the complexity of implementing this now and what that means for other things AC has in store, and it's frustrating some React target dates for Suspense launch have been missed, but I feel there's an opportunity to leverage AC early adopters (that likely came here in search of the best data fetching implementation) to help prepare AC@experimental for when Suspense does launch. |
I was trying to make it work with
import { useQuery } from "@apollo/client"
import { suspend } from "lib/suspend"
export const useSuspendableQuery = (...args) => {
const result = useQuery(...args)
if (result.loading) {
suspend(new Promise((resolve) => !result.loading && resolve())).read()
}
return result
}
export const suspend = (promise) => {
let status = "pending"
let response
const suspender = promise.then(
(res) => {
status = "success"
response = res
},
(err) => {
status = "error"
response = err
}
)
const read = () => {
switch (status) {
case "pending":
throw suspender
case "error":
throw response
default:
return response
}
}
const result = { read }
return result
} Usageconst Parent = () => {
return (
<React.Suspense fallback="Loading...">
<Child />
</React.Suspense>
)
}
const Child = () => {
const { data } = useSuspendableQuery(MY_QUERY)
return data.field
} |
These directives allows to fetch data incrementally. And I want to wrap components corresponding to const fragment = gql`
fragment ProductDetail on Product {
# selections to be fetched lazy
}
`
const DeferredFragmentComponent = ({ fragmentId }) => {
// It's similar to client.readFragment but useFragment can throw promise if the fragment is annotated as deferred and the data is not delivered
const { fragmentData } = useFragment({ id: fragmentId, fragment });
return (...) // render fragmentData
}
const QueryComponent = () => {
const { queryData: { product } } = useQuery(gql`
query MyQuery {
product(id: "p100") {
__typename
id
name
...ProductDetail @defer
}
}
${fragment}
`)
return (
<>
<span>{product.name}</span>
<Suspense fallback={<Loading />}>
<DeferredFragmentComponent fragmentId={`${product.__typename}:${product.id}`} />
</Suspense>
</>
)
} Is there anything you are planning about this? |
I'm using Apollo with NextJS (with concurrent mode enabled obviously). I tried @Fasosnql's If I try @rafbgarcia's It's a bit sad to see such conservative stance on suspense support from Apollo's maintainers TBH: it's supported by literally all other data fetching libraries by now... |
I've been patiently waiting for over a year for this feature to get implemented so I can standardize the way I structure my code around |
Just wanted to link this issue apollographql/apollo-client#5870 because it seems related according to this comment from Dan Abramov. Maybe it can help to better understand what's involved in this issue |
I'm super interested in this too ... expanding on what @rafbgarcia posted, as that solution wouldn't handle the error case very well ... I've got this currently import { ApolloQueryResult, useApolloClient } from '@apollo/client';
import { OperationVariables } from '@apollo/client/core';
import { QueryOptions } from '@apollo/client/core/watchQueryOptions';
import equal from 'deep-equal';
const suspend = <TData = any>(promise: Promise<ApolloQueryResult<TData>>) => {
let status = 'pending';
let response: ApolloQueryResult<TData>;
const suspender = promise.then(
(res) => {
status = 'success';
response = res;
},
(err) => {
status = 'error';
response = err;
}
);
const read = () => {
switch (status) {
case 'pending':
throw suspender;
case 'error':
throw response;
default:
return response;
}
};
return { read };
};
const suspenseCache: any = {};
export const useSuspenseQuery = <TData = any, TVariables = OperationVariables>(
key: string,
options: QueryOptions<TVariables, TData>
): TData => {
const client = useApolloClient();
// To prevent creating a Promise (and showing the Suspense loader for 1 render)
// when the data is already cached.
const optimisticResult = client.readQuery(options);
if (optimisticResult) {
return optimisticResult;
}
if (
!suspenseCache[key] ||
!equal(suspenseCache[key].options.variables, options.variables)
) {
const query = client.query(options);
suspenseCache[key] = {
suspender: suspend(query),
options,
};
}
return suspenseCache[key].suspender.read().data;
}; The main difference is that we have to return the same promise each time the hook is called, which means needing to save it in a cache, and thus needing to give each query a cache key (I feel like I learned this from the react-query source code). Note:
Seems to be working ok, so just sharing. Edit:
|
highly recommend checking out urql if you want to use suspense with a graphql backend. It has builtin suspense support, just init the client with suspense: true and you are good to go. Everything else works basically the same as apollo, at least so far for me. and if you are using typescript https://www.graphql-code-generator.com/docs/plugins/typescript-urql works great for generating typed hooks, you don't have to apply the types to the hooks yourself so its much more convenient. |
React 18 will bring Suspense for Data Fetching, and they're stating that their API in the published alpha is stable enough for library authors. Does this mean we could see it in Apollo Client soon? Just really trying not to migrate the codebase to urql just yet... |
@rajington Yes, definitely. Now that things have stabilized, we'll be tackling this in Apollo Client 4. That work is being tracked here: #366 |
Hi all - we're tracking this work in #366. Please join in the discussion over there if you're still interested in this functionality. Thanks! |
Instead of messing around with the Edit: Make your lives easier and use kanitsharma/react-suspender instead. // Suspender.ts
export function Suspender(): ReactElement {
const resolve = useRef<() => void>();
const promise = useMemo(() => new Promise<void>((res) => {
resolve.current = res;
}), []);
useEffect(() => {
return () => {
resolve.current?.();
};
});
throw promise;
} Then, in your component, render the // Dashboard.tsx
export function Dashboard() {
const { data, loading } = useQuery(ME_QUERY);
if (loading) {
return <Suspender/>;
}
return (
<div>
<h1>Hello {data?.me?.firstName}</h1>
</div>
);
} Have a look at my gist to see the full example: https://gist.github.com/llamadeus/ff8ffe7ac156e545575dad81142f1b6f |
I wanted to keep both the code-generated typings and re-use the existing code-generated hooks (for easier migration). And got a pretty simple solution: // useSuspendedQuery.ts
import type { QueryHookOptions, QueryTuple } from '@apollo/client';
import { suspend } from 'suspend-react';
type UseLazyQuery<TData, TVariables> = (options?: QueryHookOptions<TData, TVariables>) => QueryTuple<TData, TVariables>;
export function useSuspendedQuery<Query, Variables>(
useHook: UseLazyQuery<Query, Variables>,
baseOptions?: QueryHookOptions<Query, Variables>
): Query {
const [fetchData] = useHook();
return suspend(async () => {
const result = await fetchData(baseOptions);
if (!result.data) {
throw new Error('No data');
}
return result.data;
}, [useHook.name, JSON.stringify(baseOptions?.variables)]);
} // Dashboard.tsx
export function Dashboard() {
const data = useSuspendedQuery(useMeLazyQuery);
return (
<div>
<h1>Hello {data?.me?.firstName}</h1>
</div>
);
} |
After a a few tries a get this It is still some options mapping a refetch functions and stuff like this it works import {
DocumentNode,
OperationVariables,
QueryHookOptions,
TypedDocumentNode,
useApolloClient,
ApolloQueryResult,
} from "@apollo/client";
import { useRef, useState, useSyncExternalStore } from "react";
function deepEqual(objA: any, objB: any, map = new WeakMap()): boolean {
// P1
if (Object.is(objA, objB)) return true;
// P2
if (objA instanceof Date && objB instanceof Date) {
return objA.getTime() === objB.getTime();
}
if (objA instanceof RegExp && objB instanceof RegExp) {
return objA.toString() === objB.toString();
}
// P3
if (
typeof objA !== "object" ||
objA === null ||
typeof objB !== "object" ||
objB === null
) {
return false;
}
// P4
if (map.get(objA) === objB) return true;
map.set(objA, objB);
// P5
const keysA = Reflect.ownKeys(objA);
const keysB = Reflect.ownKeys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (let i = 0; i < keysA.length; i++) {
if (
!Reflect.has(objB, keysA[i]) ||
!deepEqual(objA[keysA[i]], objB[keysA[i]], map)
) {
return false;
}
}
return true;
}
export function useSuspendedQuery<TData = any, TVariables = OperationVariables>(
query: DocumentNode | TypedDocumentNode<TData, TVariables>,
props?: QueryHookOptions<TData, TVariables>
): ApolloQueryResult<TData> {
const client = useApolloClient();
const snapShotCache = useRef<ApolloQueryResult<TData>>();
const [observedQuery] = useState(() => {
const obsQuery = client.watchQuery<TData, TVariables>({ query, ...props });
return obsQuery;
});
const data = useSyncExternalStore(
(store) => {
const unSub = observedQuery.subscribe(() => {
store();
});
return () => {
unSub.unsubscribe();
};
},
() => {
const result = observedQuery.getCurrentResult();
const isEqual = deepEqual(snapShotCache.current, result);
const newValue = (
isEqual ? snapShotCache.current : result
) as ApolloQueryResult<TData>;
if (!isEqual) {
snapShotCache.current = newValue;
}
console.log("isEqual", isEqual);
console.log("hasData", Boolean(newValue.data));
return newValue;
}
);
const cache = client.readQuery<TData, TVariables>({ query, ...props });
if (!cache) {
const { fetchPolicy, ...newProps } = props ?? {};
const policy = client.defaultOptions.query?.fetchPolicy;
throw client.query({
query,
...newProps,
fetchPolicy: policy,
});
}
return data;
} |
Sorry if this has already been discussed but I couldn't find any feature request for supporting React Suspense api.
Although Suspense isn't officially ready for data fetching, some libraries already take advantage of this feature (react-hooks-fetch, react-apollo-hooks, react-i18next, built-in React.lazy...).
Why
Data loading is quite painful to handle at the time, especially when you have multiple queries.
Code handling data loading is also very repetitive.
Solution
Similar to what
react-apollo-hooks
did, what about adding a booleansuspend
experimental option (which defaults tofalse
) that would makeuseQuery
throw the query promise to trigger the Suspense api?This would certainly comes with some caveats like the fact it would only work with a
cache-first
fetch policy, but most of the time benefits of triggeringSuspense
are greater than caveats:<Suspense>
componentQuestion is: what is the roadmap about Suspense support ? Would it be possible to have an experimental implementation in a near future? or wait for the Suspense api to be officially ready for data fetching?
Thanks
The text was updated successfully, but these errors were encountered: