JavaScript and React course Β· Module 10: React Ecosystem and Future
React Query / TanStack Query - Modern Server State Management
In this lesson6
The mission panel shows an astronaut's profile in three components at once. With useEffect and fetch, each one sends a separate request, none of them remembers the result, and switching profiles quickly can show data belonging to the wrong person. React Query, today called TanStack Query, changes the way you fetch, cache and synchronize server state with the UI. Like a navigation system that tracks the ship's position by itself, the library manages the state of your data on its own.
Why React Query Instead of useEffect + fetch?
Look at the traditional approach and the list of its problems in the comments:
1// Traditional approach - MANY PROBLEMS
2function UserProfile({ userId }) {
3 const [user, setUser] = useState(null);
4 const [loading, setLoading] = useState(true);
5 const [error, setError] = useState(null);
6
7 useEffect(() => {
8 setLoading(true);
9 fetch(`/api/users/${userId}`)
10 .then(res => res.json())
11 .then(data => {
12 setUser(data);
13 setLoading(false);
14 })
15 .catch(err => {
16 setError(err);
17 setLoading(false);
18 });
19 }, [userId]);
20
21 // Problems:
22 // 1. No caching - same data fetched multiple times
23 // 2. No automatic refetching
24 // 3. No optimistic updates
25 // 4. No request deduplication
26 // 5. No stale data handling
27 // 6. Must manually manage loading/error states
28 // 7. Race condition issues
29
30 if (loading) return <div>Loading...</div>;
31 if (error) return <div>Error: {error.message}</div>;
32 return <div>{user.name}</div>;
33}You manage the loading and error states by hand, and when userId changes quickly an older response can overwrite a newer one, which is a race condition. Nothing here is cached or deduplicated.
The same component with TanStack Query looks like this:
1// React Query - ELEGANT AND POWERFUL
2import { useQuery } from '@tanstack/react-query';
3
4function UserProfile({ userId }) {
5 const { data: user, isPending, error } = useQuery({
6 queryKey: ['user', userId],
7 queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
8 });
9
10 if (isPending) return <div>Loading...</div>;
11 if (error) return <div>Error: {error.message}</div>;
12 return <div>{user.name}</div>;
13}Every query needs two things: queryKey, a unique key in the cache, and queryFn, a function that returns a promise with the data. The ['user', userId] key gives each user a separate entry, and three components with the same key will send one request. isPending means there is no data yet, so after both checks user is defined. Remember that fetch does not throw an error on a 404 or 500 status, so check res.ok, as createMission does below.
Installation and Basic Configuration
You install the main package and the optional developer tools:
1# Installing TanStack Query (React Query v5)
2npm install @tanstack/react-query
3npm install @tanstack/react-query-devtools # Optional DevToolsDevTools end up in the bundle only in development mode, so you do not have to remove them before deploying.
QueryClient stores the cache and the default query options, and QueryClientProvider makes it available to the application:
1// App.jsx - Setup QueryClient
2import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
4
5// Create Query Client with configuration
6const queryClient = new QueryClient({
7 defaultOptions: {
8 queries: {
9 staleTime: 1000 * 60 * 5, // 5 minutes
10 gcTime: 1000 * 60 * 30, // 30 minutes
11 refetchOnWindowFocus: true,
12 refetchOnReconnect: true,
13 retry: 3,
14 retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
15 },
16 },
17});
18
19function App() {
20 return (
21 <QueryClientProvider client={queryClient}>
22 <YourApp />
23 {/* DevTools - visible only in development */}
24 <ReactQueryDevtools initialIsOpen={false} />
25 </QueryClientProvider>
26 );
27}staleTime says how long data stays fresh, and gcTime (cacheTime in version 4) after what time an unused entry disappears from memory. By default staleTime is 0 and gcTime is 5 minutes.
The Query Lifecycle: status and fetchStatus
The stale-while-revalidate strategy means that you show data from the cache immediately, even if it is stale, while in the background the library fetches fresh data and swaps it into the UI. To tell what is happening at any moment, TanStack Query v5 describes a query with two independent fields. status is about the data: pending (there is none yet, loading in version 4), error or success. fetchStatus is about the queryFn function: fetching (it is running), paused (it wants to fetch, but there is no network) or idle (it is doing nothing). The idle and loading values of status no longer exist:
1function MissionCounter() {
2 const { data, status, fetchStatus, isStale } = useQuery({
3 queryKey: ['missions'],
4 queryFn: fetchMissions,
5 staleTime: 30 * 1000, // Data stays fresh for 30 seconds
6 });
7
8 if (status === 'pending') return <p>First load (fetchStatus: {fetchStatus})</p>;
9 if (status === 'error') return <p>Failed to fetch missions</p>;
10
11 return (
12 <p>
13 Missions: {data.length}, data is {isStale ? 'stale' : 'fresh'}
14 {fetchStatus === 'fetching' && ' - refreshing in the background'}
15 </p>
16 );
17}This is the typical path of a query from the moment the component mounts:
- Mount - there is no data, so
statusispendingandfetchStatusisfetching. Only in this situation isisLoading, meaningisPending && isFetching,true. - Success - the data has arrived:
statuschanges tosuccessandfetchStatustoidle. For thestaleTimeperiod the data is fresh and the library sends no new requests. - Stale data - once
staleTimepasses, the data becomes stale (isStaleistrue), but that alone does not fetch anything yet. By defaultstaleTimeis 0, so data is stale right after it is fetched. - Background refresh - a trigger, such as a new component with the same key, returning to the browser tab (
refetchOnWindowFocus), the network reconnecting (refetchOnReconnect) orinvalidateQueries, starts a fetch.statusstayssuccessand the old data is still visible, whilefetchStatuschanges tofetching, soisFetchingistrueandisLoadingisfalse. - Idle again - the fresh data replaces the old data,
fetchStatusgoes back toidle, and thestaleTimecountdown starts over.
Without a network the query does not fail, it switches to fetchStatus: 'paused' and resumes by itself once the connection is back. refetchInterval refreshes data periodically, independently of staleTime.
useMutation - POST, PUT, DELETE requests
Queries read data, and mutations change it. useMutation takes a mutationFn and sends nothing by itself, it only returns an object with a mutate method:
1import { useMutation, useQueryClient } from '@tanstack/react-query';
2
3async function createMission(newMission) {
4 const response = await fetch('/api/missions', {
5 method: 'POST',
6 headers: { 'Content-Type': 'application/json' },
7 body: JSON.stringify(newMission),
8 });
9
10 if (!response.ok) {
11 throw new Error('Failed to create mission');
12 }
13
14 return response.json();
15}
16
17function CreateMissionForm() {
18 const queryClient = useQueryClient();
19
20 const mutation = useMutation({
21 mutationFn: createMission,
22
23 // Optimistic update
24 onMutate: async (newMission) => {
25 await queryClient.cancelQueries({ queryKey: ['missions'] });
26 const previousMissions = queryClient.getQueryData(['missions']);
27 queryClient.setQueryData(['missions'], (old) => [...old, newMission]);
28 return { previousMissions };
29 },
30
31 // Rollback on error
32 onError: (err, newMission, context) => {
33 queryClient.setQueryData(['missions'], context.previousMissions);
34 },
35
36 // Invalidate and refetch
37 onSettled: () => {
38 queryClient.invalidateQueries({ queryKey: ['missions'] });
39 },
40 });
41
42 const handleSubmit = (e) => {
43 e.preventDefault();
44 const formData = new FormData(e.target);
45
46 mutation.mutate({
47 name: formData.get('name'),
48 destination: formData.get('destination'),
49 date: formData.get('date'),
50 });
51 };
52
53 return (
54 <form onSubmit={handleSubmit}>
55 <input name="name" placeholder="Mission name" required />
56 <input name="destination" placeholder="Mission destination" required />
57 <input name="date" type="date" required />
58
59 <button type="submit" disabled={mutation.isPending}>
60 {mutation.isPending ? 'Creating...' : 'Create Mission'}
61 </button>
62 </form>
63 );
64}This is an optimistic update: onMutate immediately adds the mission to the cache, before the server responds, and remembers the previous list. On error, onError restores it from context, and onSettled invalidates the ['missions'] key, so the list syncs with the server either way. cancelQueries stops an ongoing fetch so it does not overwrite the change, and isPending disables the button.
Pagination with React Query
With pagination the page number goes into the key, so each page has a separate entry in the cache:
1import { useQuery, keepPreviousData } from '@tanstack/react-query';
2
3function PaginatedMissions() {
4 const [page, setPage] = useState(1);
5
6 const { data, isLoading, isPlaceholderData } = useQuery({
7 queryKey: ['missions', page],
8 queryFn: () => fetchMissions(page),
9 placeholderData: keepPreviousData,
10 });
11
12 return (
13 <div>
14 {isLoading ? (
15 <div>Loading...</div>
16 ) : (
17 <>
18 <div className="missions-list">
19 {data.missions.map(mission => (
20 <div key={mission.id} className="mission-item">
21 <h3>{mission.name}</h3>
22 </div>
23 ))}
24 </div>
25
26 <div className="pagination">
27 <button
28 onClick={() => setPage(old => Math.max(old - 1, 1))}
29 disabled={page === 1}
30 >
31 Previous
32 </button>
33
34 <span>Page {page} of {data.totalPages}</span>
35
36 <button
37 onClick={() => setPage(old => old + 1)}
38 disabled={isPlaceholderData || page >= data.totalPages}
39 >
40 Next
41 </button>
42 </div>
43 </>
44 )}
45 </div>
46 );
47}placeholderData: keepPreviousData keeps the previous page on screen until the next one arrives, so the list does not flicker. isPlaceholderData tells you that you are still seeing old data, which is why the Next button waits.
Infinite Queries - Infinite Scrolling
useInfiniteQuery stores all the fetched pages of an infinite list in data.pages:
1import { useInfiniteQuery } from '@tanstack/react-query';
2
3function InfiniteMissions() {
4 const {
5 data,
6 fetchNextPage,
7 hasNextPage,
8 isFetchingNextPage,
9 status,
10 } = useInfiniteQuery({
11 queryKey: ['infiniteMissions'],
12 queryFn: ({ pageParam }) => fetch(`/api/missions?page=${pageParam}&limit=20`).then(r => r.json()),
13 initialPageParam: 1,
14 getNextPageParam: (lastPage, allPages) => {
15 return lastPage.hasMore ? allPages.length + 1 : undefined;
16 },
17 });
18
19 if (status === 'pending') return <div>Loading...</div>;
20 if (status === 'error') return <div>Loading error</div>;
21
22 return (
23 <div>
24 <div className="infinite-list">
25 {data.pages.map((page, i) => (
26 <React.Fragment key={i}>
27 {page.missions.map(mission => (
28 <div key={mission.id} className="mission-card">
29 <h3>{mission.name}</h3>
30 </div>
31 ))}
32 </React.Fragment>
33 ))}
34 </div>
35
36 <button
37 onClick={() => fetchNextPage()}
38 disabled={!hasNextPage || isFetchingNextPage}
39 >
40 {isFetchingNextPage
41 ? 'Loading...'
42 : hasNextPage
43 ? 'Load More'
44 : 'No more data'}
45 </button>
46 </div>
47 );
48}initialPageParam (mandatory in version 5) sets the first page, and getNextPageParam returns the next page number or undefined when the data runs out, which sets hasNextPage to false.
I recommend keeping server data in TanStack Query, and only UI state, like open panels, in useState or Redux, because these are two different kinds of state. The library automates fetching, caching, synchronizing and updating data like an autopilot that corrects its course by itself, while you focus on the user experience. It will come in handy in every project with an API, because queryFn can be any function that returns a promise, including a GraphQL client.
Remember: a query key is a set of coordinates in the cache - the same coordinates mean the same data, and stale data refreshes in the background. In the example below, watch the status and fetchStatus fields: wait 5 seconds until the data goes stale, return to the tab, and then add a mission, also with a simulated server error.
Code for this lesson: App.jsx
1import { useEffect, useState, useSyncExternalStore } from 'react';
2
3// === TanStack Query v5 - a simplified cache simulation ===
4// In a project: npm install @tanstack/react-query and useQuery({ queryKey, queryFn, staleTime }).
5// This mini client shows the same fields as the library: status (pending | error | success),
6// fetchStatus (fetching | paused | idle), and fresh or stale data once staleTime passes.
7
8const STALE_TIME = 5000; // Data stays fresh for 5 seconds
9const startedAt = Date.now();
10
11// A fake server with a list of missions
12let serverMissions = [
13 { id: 1, name: 'Apollo-RQ1', status: 'active', crew: 4 },
14 { id: 2, name: 'Nebula-X7', status: 'completed', crew: 6 },
15 { id: 3, name: 'Orion-3', status: 'planned', crew: 3 },
16];
17
18const api = {
19 getMissions: () => new Promise(resolve => setTimeout(() => resolve([...serverMissions]), 800)),
20 addMission: (mission, fail) => new Promise((resolve, reject) => setTimeout(() => {
21 if (fail) {
22 reject(new Error('The server rejected the mission'));
23 return;
24 }
25 const saved = { ...mission, id: Date.now() };
26 serverMissions = [...serverMissions, saved];
27 resolve(saved);
28 }, 700)),
29};
30
31// The cache entry for the ['missions'] key - QueryClient does this for you
32function createQuery(queryFn) {
33 let state = { status: 'pending', fetchStatus: 'idle', data: undefined, error: null, dataUpdatedAt: 0, isStale: true, log: [] };
34 let inFlight = null;
35 let fetchId = 0;
36 let staleTimer = null;
37 const listeners = new Set();
38
39 const setState = (patch, message) => {
40 const time = ((Date.now() - startedAt) / 1000).toFixed(1) + ' s';
41 const log = message ? [...state.log, { time, message }].slice(-10) : state.log;
42 state = { ...state, ...patch, log };
43 listeners.forEach(listener => listener());
44 };
45
46 const markFresh = (data, message) => {
47 clearTimeout(staleTimer);
48 setState({ status: 'success', data, error: null, dataUpdatedAt: Date.now(), isStale: false, fetchStatus: 'idle' }, message);
49 staleTimer = setTimeout(() => setState({ isStale: true }, 'staleTime passed: data is stale, but nothing is fetched yet'), STALE_TIME);
50 };
51
52 const query = {
53 subscribe(listener) {
54 listeners.add(listener);
55 return () => listeners.delete(listener);
56 },
57 getState() {
58 return state;
59 },
60 fetch(reason) {
61 if (inFlight) return inFlight; // Deduplication: one request at a time
62 if (state.status === 'success') {
63 setState({ fetchStatus: 'fetching' }, 'fetchStatus: fetching, status still success - background refresh (' + reason + ')');
64 } else {
65 setState({ fetchStatus: 'fetching' }, 'status: pending, fetchStatus: fetching - first load (isLoading: true)');
66 }
67 const id = ++fetchId;
68 inFlight = queryFn()
69 .then(data => {
70 if (id === fetchId) markFresh(data, 'status: success, fetchStatus: idle - data fresh for ' + STALE_TIME / 1000 + ' s');
71 })
72 .catch(error => {
73 if (id === fetchId) setState({ status: 'error', error, fetchStatus: 'idle' }, 'status: error - ' + error.message);
74 })
75 .finally(() => {
76 if (id === fetchId) inFlight = null;
77 });
78 return inFlight;
79 },
80 // cancelQueries: the result of the ongoing fetch will be ignored
81 cancel() {
82 if (!inFlight) return;
83 fetchId++;
84 inFlight = null;
85 setState({ fetchStatus: 'idle' }, 'cancelQueries: the ongoing fetch was cancelled');
86 },
87 refetchIfStale(reason) {
88 if (state.isStale) query.fetch(reason);
89 else if (state.status === 'success') setState({}, reason + ': data is fresh, so no new request');
90 },
91 invalidate(reason) {
92 clearTimeout(staleTimer);
93 setState({ isStale: true });
94 return query.fetch(reason);
95 },
96 setData(updater, message) {
97 markFresh(updater(state.data), message);
98 },
99 };
100 return query;
101}
102
103const missionsQuery = createQuery(api.getMissions);
104
105// The equivalent of useQuery({ queryKey: ['missions'], queryFn, staleTime: 5000 })
106function useMissionsQuery() {
107 const state = useSyncExternalStore(missionsQuery.subscribe, missionsQuery.getState);
108
109 // Component mount: fetch if there is no data or it is stale
110 useEffect(() => {
111 missionsQuery.refetchIfStale('new component');
112 }, []);
113
114 // refetchOnWindowFocus: returning to the tab refreshes only stale data
115 useEffect(() => {
116 const onFocus = () => missionsQuery.refetchIfStale('window focus');
117 window.addEventListener('focus', onFocus);
118 return () => window.removeEventListener('focus', onFocus);
119 }, []);
120
121 return {
122 ...state,
123 isPending: state.status === 'pending',
124 isFetching: state.fetchStatus === 'fetching',
125 isLoading: state.status === 'pending' && state.fetchStatus === 'fetching',
126 };
127}
128
129function Badge({ label, value, color }) {
130 return (
131 <span style={{ ...styles.badge, borderColor: color, color }}>
132 {label}: <strong>{String(value)}</strong>
133 </span>
134 );
135}
136
137function QueryStatus({ query }) {
138 return (
139 <div style={styles.card}>
140 <div style={styles.badges}>
141 <Badge label="status" value={query.status} color="#00d4ff" />
142 <Badge label="fetchStatus" value={query.fetchStatus} color={query.isFetching ? '#ffa500' : '#8892b0'} />
143 <Badge label="isLoading" value={query.isLoading} color="#8892b0" />
144 <Badge label="isFetching" value={query.isFetching} color="#8892b0" />
145 <Badge label="data" value={query.isStale ? 'stale' : 'fresh'} color={query.isStale ? '#ff6b6b' : '#00ff88'} />
146 </div>
147 <div style={styles.staleTrack}>
148 {!query.isStale && <div key={query.dataUpdatedAt} style={styles.staleFill} />}
149 </div>
150 <div style={styles.buttons}>
151 <button style={styles.button} onClick={() => missionsQuery.refetchIfStale('window focus')}>
152 Simulate returning to the tab
153 </button>
154 <button style={styles.button} onClick={() => missionsQuery.invalidate('invalidateQueries')}>
155 invalidateQueries(['missions'])
156 </button>
157 </div>
158 </div>
159 );
160}
161
162function AddMission({ ready }) {
163 const [name, setName] = useState('');
164 const [failServer, setFailServer] = useState(false);
165 const [isPending, setIsPending] = useState(false);
166
167 // The equivalent of useMutation with an optimistic update
168 const addMission = async () => {
169 const mission = { name: name.trim(), status: 'planned', crew: 2 };
170 if (!mission.name || isPending || !ready) return;
171 setName('');
172 setIsPending(true);
173
174 // onMutate: cancel the ongoing fetch, remember the list and add the mission right away
175 missionsQuery.cancel();
176 const previous = missionsQuery.getState().data;
177 missionsQuery.setData(old => [...old, { ...mission, id: 'temp-' + Date.now(), optimistic: true }], 'onMutate: mission added optimistically');
178 try {
179 await api.addMission(mission, failServer);
180 } catch (error) {
181 // onError: restore the list from before the change
182 missionsQuery.setData(() => previous, 'onError: ' + error.message + ' - the previous list was restored');
183 } finally {
184 // onSettled: invalidate the key to sync with the server
185 setIsPending(false);
186 missionsQuery.invalidate('onSettled');
187 }
188 };
189
190 return (
191 <div style={styles.form}>
192 <input
193 value={name}
194 onChange={e => setName(e.target.value)}
195 onKeyDown={e => e.key === 'Enter' && addMission()}
196 placeholder="New mission name..."
197 style={styles.input}
198 />
199 <button onClick={addMission} disabled={isPending || !ready} style={styles.primary}>
200 {isPending ? 'Saving...' : 'Add mission'}
201 </button>
202 <label style={styles.check}>
203 <input type="checkbox" checked={failServer} onChange={e => setFailServer(e.target.checked)} />
204 Simulate a server error
205 </label>
206 </div>
207 );
208}
209
210export default function App() {
211 const query = useMissionsQuery();
212
213 return (
214 <div style={styles.page}>
215 <div style={{ maxWidth: '640px', margin: '0 auto' }}>
216 <h1 style={styles.title}>TanStack Query v5 - the query lifecycle</h1>
217 <p style={styles.subtitle}>staleTime: {STALE_TIME / 1000} s. Wait until the data goes stale, then return to the tab.</p>
218
219 <QueryStatus query={query} />
220
221 <div style={styles.card}>
222 {query.isPending ? (
223 <p style={styles.muted}>Loading missions...</p>
224 ) : query.status === 'error' ? (
225 <p style={{ color: '#ff6b6b' }}>Error: {query.error.message}</p>
226 ) : (
227 query.data.map(m => (
228 <div key={m.id} style={{ ...styles.mission, opacity: m.optimistic ? 0.6 : 1 }}>
229 <strong>{m.name}</strong>
230 <span style={styles.muted}>
231 {m.optimistic ? 'saving (optimistic)' : m.status + ', crew: ' + m.crew}
232 </span>
233 </div>
234 ))
235 )}
236 <AddMission ready={query.status === 'success'} />
237 </div>
238
239 <div style={styles.card}>
240 <h3 style={styles.logTitle}>Query log</h3>
241 {query.log.map((entry, i) => (
242 <div key={i} style={styles.logRow}>
243 <span style={styles.logTime}>{entry.time}</span>
244 <span>{entry.message}</span>
245 </div>
246 ))}
247 </div>
248 </div>
249 <style>{'@keyframes staleCountdown { from { width: 100%; } to { width: 0%; } }'}</style>
250 </div>
251 );
252}
253
254const styles = {
255 page: { minHeight: '100vh', background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)', padding: '20px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
256 title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 6px', fontSize: '20px' },
257 subtitle: { textAlign: 'center', color: '#8892b0', margin: '0 0 14px' },
258 card: { background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(0,212,255,0.15)', borderRadius: '10px', padding: '12px', marginBottom: '12px' },
259 badges: { display: 'flex', flexWrap: 'wrap', gap: '6px' },
260 badge: { border: '1px solid', borderRadius: '6px', padding: '3px 8px', fontSize: '12px' },
261 staleTrack: { height: '4px', background: 'rgba(255,255,255,0.08)', borderRadius: '2px', margin: '10px 0', overflow: 'hidden' },
262 staleFill: { height: '100%', background: '#00ff88', animation: 'staleCountdown 5s linear forwards' },
263 buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap' },
264 button: { padding: '6px 12px', background: 'rgba(0,212,255,0.15)', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.4)', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' },
265 mission: { display: 'flex', justifyContent: 'space-between', gap: '8px', padding: '8px 10px', margin: '6px 0', background: 'rgba(0,212,255,0.06)', borderRadius: '6px' },
266 muted: { color: '#8892b0' },
267 form: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '10px' },
268 input: { flex: '1 1 180px', padding: '7px 10px', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', color: '#e0e1dd' },
269 primary: { padding: '7px 14px', background: '#00d4ff', color: '#0a0e27', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
270 check: { display: 'flex', alignItems: 'center', gap: '6px', color: '#8892b0' },
271 logTitle: { margin: '0 0 8px', color: '#64ffda', fontSize: '14px' },
272 logRow: { display: 'flex', gap: '8px', padding: '2px 0', fontSize: '12px' },
273 logTime: { color: '#8892b0', width: '48px', flexShrink: 0, textAlign: 'right' },
274};Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is required in the useQuery configuration?
2. What is an optimistic update in the context of useMutation?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the elements of a useMutation call in React Query from left to right:
- Vertical ordering
Order the query lifecycle in TanStack Query v5, from mounting the component to a background refetch: