|
| 1 | +# Motivation |
| 2 | + |
| 3 | +## We don't want to use `useSuspenseQuery` or `useSuspenseQueries` (_in fact we want to disallow them_) |
| 4 | + |
| 5 | +1. Developers can put `useSuspenseQuery` inside custom hooks like |
| 6 | + `useUserProfile`. Let's say that happens in 2 such hooks: `useUserProfile` |
| 7 | + and `useProducts`. When a specific page/component wants to use **BOTH** |
| 8 | + custom hooks, this automatically creates a sequential, aka waterfall |
| 9 | + fetching, even if the queries don't depend on each other. We want to solve |
| 10 | + that by always using `useQuery` + `experimental_prefetchInRender` instead of |
| 11 | + `useSuspenseQuery` and then the custom hooks should return a promise |
| 12 | + themselves that the developer of each page can decide when to suspense via |
| 13 | + `use()`. |
| 14 | + |
| 15 | + > [!NOTE] |
| 16 | + > |
| 17 | + > Out of the box `experimental_prefetchInRender` doesn't work on the server. |
| 18 | + > That's why we need to |
| 19 | + > [patch `@tanstack/react-query`](../patches/@tanstack__react-query.patch). |
| 20 | +
|
| 21 | +2. `useSuspenseQuery` provides `refetch` but calling refetch **does not |
| 22 | + trigger** the `<Suspense/>` boundary so even if we had a Suspense fallback |
| 23 | + that handles the initial loading, now we need another internal loading inside |
| 24 | + of the component. We want to solve that by having the promise from `useQuery` |
| 25 | + passed to `use` which will trigger again the Suspense. |
| 26 | + |
| 27 | +3. We also want to be able to have |
| 28 | + [dependent queries](https://tanstack.com/query/latest/docs/framework/react/guides/dependent-queries) |
| 29 | + (_a.k.a. queryA which can only start after queryB_) and wrap them in custom |
| 30 | + hooks. These custom hooks should be able to **return a single promise** that |
| 31 | + returns specific value. So that pages/components using that hook can control |
| 32 | + when to suspend and get the data. |
| 33 | + |
| 34 | + > [!NOTE] |
| 35 | + > |
| 36 | + > This doesn't work out of the box because having dependent `useQuery` calls |
| 37 | + > means that some hooks are disabled until a re-render happens during which |
| 38 | + > their required data is available. So if we simply suspend on the last query |
| 39 | + > promise (_this query will not be enabled during the first render because it |
| 40 | + > relies on the previous queries_) our rendering will get stuck forever |
| 41 | + > because we'll never re-render which means we'll never enable the disabled |
| 42 | + > query. For that we came up with |
| 43 | + > [`useDependentQueryPromise`](./useDependentQueryPromise.ts) which does some |
| 44 | + > magic under the hood to help us. |
| 45 | +
|
| 46 | +## Invalidating data must trigger re-rendering of relevant components |
| 47 | + |
| 48 | +Generally speaking, there are two main ways to fetch data. To use hooks like |
| 49 | +`useQuery` that use the React re-rendering cycle _OR_ to rely on basic async |
| 50 | +function calls. |
| 51 | + |
| 52 | +The second option might seem alluringly simple. However that option doesn't come |
| 53 | +with a solution on how to trigger refresh of the UI, aka re-rendering of the UI, |
| 54 | +when some data is no longer up to date (has changed). Yes, we can put the data |
| 55 | +inside a context and then have custom logic on optimizing which components use |
| 56 | +which data so we know what to rerender. But even then, we are lacking the |
| 57 | +caching layer of solutions like `useQuery`. So basically we'll end up |
| 58 | +re-implementing it. |
| 59 | + |
| 60 | +So we use `useQuery`. You might think that we can simplify the complications of |
| 61 | +dependent queries & React `use()` by not having dependent queries at all, and |
| 62 | +instead have `queryClient.fetchQuery` calls inside of the `queryFn` when we need |
| 63 | +more data. And yes this is possible, however this means that the query that uses |
| 64 | +`queryClient.fetchQuery` inside of its `queryFn` can't have data returned by |
| 65 | +internal `queryClient.fetchQuery` as part of its `queryKey` so this is not |
| 66 | +allowing us a good `queryKey` which means well-targeted cache invalidation is |
| 67 | +not possible. |
| 68 | + |
| 69 | +# The solution |
| 70 | + |
| 71 | +This comes in 4 parts: |
| 72 | + |
| 73 | +1. Enabling `experimental_prefetchInRender` for react query `QueryClient` |
| 74 | +2. The mentioned above |
| 75 | + [patch of `@tanstack/react-query`](../patches/@tanstack__react-query.patch) |
| 76 | +3. The [`useDependentQueryPromise`](./useDependentQueryPromise.ts) helper to |
| 77 | + make React play well with dependent `useQuery` that require re-rendering to |
| 78 | + figure out they need to start |
| 79 | +4. Strict convention on how to structure hooks that retrieve data, when to call |
| 80 | + them and when to suspend their promises. |
| 81 | + |
| 82 | +## Usage |
| 83 | + |
| 84 | +Inside custom hooks: |
| 85 | + |
| 86 | +```tsx |
| 87 | +export function useAsyncUserProfile(): { |
| 88 | + promise: Promise<UserProfile>; |
| 89 | + profile: UserProfile | null; |
| 90 | + isFetching: boolean; |
| 91 | +} { |
| 92 | + const currentUserQuery = useAsyncCurrentUser(); |
| 93 | + const userProfileQuery = useQuery( |
| 94 | + getUserProfileQueryOptions(currentUserQuery.data?.id), |
| 95 | + ); |
| 96 | + const { profile, isFetching } = userProfileQuery; |
| 97 | + |
| 98 | + return { |
| 99 | + profile, |
| 100 | + isFetching, |
| 101 | + promise: useDependentQueryPromise({ |
| 102 | + resultPromise: userProfileQuery.promise, |
| 103 | + orderedQueries: [currentUserQuery, userProfileQuery], |
| 104 | + }), |
| 105 | + }; |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +Then, usage of custom hooks inside components: |
| 110 | + |
| 111 | +> [!IMPORTANT] |
| 112 | +> |
| 113 | +> Async hooks that start the data fetching **_MUST_** all be called before any |
| 114 | +> `use()` calls |
| 115 | +
|
| 116 | +```tsx |
| 117 | +function Homepage() { |
| 118 | + // ✅ ALL custom hooks with async logic BEFORE any use() calls |
| 119 | + const { promise: settingsPromise } = useAsyncUserSettings(); |
| 120 | + const { promise: promoProductsPromise } = usePromoProducts(); |
| 121 | + const { promise: profilePromise } = useAsyncUserProfile(); |
| 122 | + |
| 123 | + // at this point, any queries that are not blocked by other queries are already running in parallel |
| 124 | + |
| 125 | + // ✅ ALL use() calls come AFTER all custom hooks with async calls |
| 126 | + const settings = use(settingsPromise); |
| 127 | + const promoProducts = use(promoProductsPromise); |
| 128 | + const profile = use(profilePromise); |
| 129 | + |
| 130 | + return ( |
| 131 | + <div> |
| 132 | + <h1>{settings.name}</h1> |
| 133 | + <p>Promo Products: {promoProducts.length}</p> |
| 134 | + <p>Profile: {profile.displayName}</p> |
| 135 | + </div> |
| 136 | + ); |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +Then, when using this component: |
| 141 | + |
| 142 | +> [!IMPORTANT] |
| 143 | +> |
| 144 | +> If you have a component that gets async data inside but you render it without |
| 145 | +> wrapping in `<Suspense>` this means you will potentially block the whole page |
| 146 | +> (_everything until the closest `<Suspense>` up the component tree_) |
| 147 | +
|
| 148 | +```tsx |
| 149 | +function UserPage() { |
| 150 | + return ( |
| 151 | + <div> |
| 152 | + <div>Some static content that doesn't need async data</div> |
| 153 | + <Suspense |
| 154 | + fallback={<Loading>Example homepage loading fallback...</Loading>} |
| 155 | + > |
| 156 | + <Homepage /> |
| 157 | + </Suspense> |
| 158 | + <Suspense |
| 159 | + fallback={<Loading>Example dynamic chart loading fallback...</Loading>} |
| 160 | + > |
| 161 | + <SomeDynamicChart /> |
| 162 | + </Suspense> |
| 163 | + <Suspense |
| 164 | + fallback={ |
| 165 | + <Loading>Example other dynamic content loading fallback...</Loading> |
| 166 | + } |
| 167 | + > |
| 168 | + <SomeDynamicContent /> |
| 169 | + </Suspense> |
| 170 | + </div> |
| 171 | + ); |
| 172 | +} |
| 173 | +``` |
0 commit comments