Data fetching patterns in React

Introduction

Data fetching patterns are strategies that can be leveraged to improve the overall application's perceived performance. By using these patterns we give the users the feeling the application is quicker than it actually is. There are multiple ways this can be achieved in a client-side application, each one of them is equally important and serves its unique purpose of improving User Experience and even Developer Experience.

The ways of caching

kylo

In-Memory Caching is one of the most important mechanisms there is in client-side applications that can be used to improve the performance perceived by the users. One thing though that's worth mentioning about it is that in-memory caching is different than client caching. While client caching is something enabled by default in modern browsers, the in-memory cache is controlled by the application itself and can be done manually or by using third-party libraries, also it can be persisted by using the native storage options available in the browsers.

In-Memory Caching is a strategy that consists of saving the fetched data in memory, usually using a Map for efficiency. A simple way of implementing this would be:

1	 // Simple in-memory cache implementation
2	 const cache = new Map()
3	 
4	 // Get cache
5	 const getCachedData = (key) => {
6	   const item = cache.get(key)
7	   
8	   if (!item) return null
9	   
10	   const now = Date.now()
11	   
12	   if (now > item.expiry) {
13	     cache.delete(key)
14	     
15	     return null
16	   }
17	   
18	   return item.data
19	 }
20	 
21	 // Set cache in memory
22	 const setCacheData = (key, data, ttlSeconds = 300) => {
23	   cache.set(key, {
24	     data,
25	     expiry: Date.now() + (ttlSeconds * 1000)
26	   })
27	 }
28	 
29	 // Usage
30	 const loadData = async (id) => {
31	   const cachedData = getCachedData(id)
32	 
33	   if (cachedData) return cachedData
34	 
35	   const { data } = await myService.getById(id)
36	   setCacheData(id, data)
37	 
38	   return data
39	 }
40	 

While this implementation may come across as enough, there are some situations that need to be taken into consideration like: error handling, robustness, performance, race conditions, stale data, state management and several other issues that may emerge from this. So, a more convenient approach would be using something that's already being used and tested by millions of other developers like Tanstack Query.

Tanstack Query

Basically, Tanstack Query is wrapper around http requests that manages queries and mutations in client-side applications. This tool makes it possible for developers to focus on building great experiences while leaving the complexity of dealing with the cache to the tool itself.

Even though Tanstack Query manages the cache by itself, it also provides a fine grained control over the cache stored in memory. It has a series of built-in methods that allows the application to step in and take control of what's happening behind the scenes.

The basic setup of a Tanstack Query instance for a React application would be:

1	 import {
2	   useQuery,
3	   useMutation,
4	   useQueryClient,
5	   QueryClient,
6	   QueryClientProvider,
7	 } from '@tanstack/react-query'
8	 import { getTodos, postTodo } from '../my-api'
9	 
10	 // Create a client
11	 const queryClient = new QueryClient()
12	 
13	 function App() {
14	   return (
15	     // Provide the client to your App
16	     <QueryClientProvider client={queryClient}>
17	       <Todos />
18	     </QueryClientProvider>
19	   )
20	 }
21	 
22	 function Todos() {
23	   // Access the client
24	   const queryClient = useQueryClient()
25	 
26	   // Queries
27	   const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })
28	 
29	   // Mutations
30	   const mutation = useMutation({
31	     mutationFn: postTodo,
32	     onSuccess: () => {
33	       // Invalidate and refetch
34	       queryClient.invalidateQueries({ queryKey: ['todos'] })
35	     },
36	   })
37	 
38	   return (
39	     <div>
40	       <ul>{query.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>
41	 
42	       <button
43	         onClick={() => {
44	           mutation.mutate({
45	             id: Date.now(),
46	             title: 'Do Laundry',
47	           })
48	         }}
49	       >
50	         Add Todo
51	       </button>
52	     </div>
53	   )
54	 }
55	 
56	 render(<App />, document.getElementById('root'))
57	 
58	 // Source: https://tanstack.com/query/latest/docs/framework/react/quick-start
59	 

The example above illustrates the basics of how to work with Tanstack Query. It covers the creation of a stable query client, wrapping your application with a QueryClientProvider, accessing the query client from inside a component anywhere in the React tree, making a query, performing a mutation and invalidating the stale data of a specific query.

How does Tanstack Query cache work?

Tanstack Query cache works around a concept called query key. Every query must have a query key associated to it, this query key is simply an array of values that's used to distinguish one query from another. The query key is used as a key for that particular query inside the in-memory cache data structure. With this key it's easy for the application to manipulate the data of a specific query.

1	 import { useQuery } from '@tanstack/react-query'
2	 import { getUser } from '../my-api'
3	 
4	 function User() {
5	   // Query key used to cache the data
6	   const queryKey = ['user', '1']
7	   
8	   const query = useQuery({ queryKey, queryFn: getTodos })
9	 
10	   return (
11	     <div>
12	 	  <span>{query.data?.name}</span>
13	 	  <span>{query.data?.address}</span>
14	     </div>
15	   )
16	 }
17	 

Now, the data for that particular user is cached, and whenever the application needs this data it'll reach for its cached version and revalidate it in background, which allows users to have a better experience as they don't need to wait for data they have already fetched before. Besides, now cached data can be accessed and manipulated using:

It's also possible to invalidate queries based on their query keys:

Another important aspect of caching is the stale data. Data is considered to be stale when the application no longer have its most up-to-date version. Tanstack Query has a stale time option, which is important for managing how long the data fetched by queries is considered fresh. The stale time defaults to 0 milliseconds, meaning that queries will be updated in background every time the component mounts or when the window is refocused. The stale time can be customized to the application's needs to optimize performance and reduce unnecessary data fetching.

1	 const { data } = useQuery({ 
2	   queryKey: ['todos'], 
3	   queryFn: fetchTodos, 
4	   staleTime: 60 * 1000, // 60 seconds 
5	 });
6	 

In the example above every time a query with a query key set to ['todos'] is made within 60 seconds Tanstack Query will use the data stored inside the cache instead of calling the api again.

Alternatively, a default stale time can be set on a query client and shared with all queries unless it's overridden by the query itself.

1	 const queryClient = new QueryClient({ 
2	   defaultOptions: { 
3	     queries: { 
4	       staleTime: 60 * 1000, // 60 seconds 
5	     }, 
6	   }, 
7	 });
8	 

For more information about caching in Tanstack Query:

Initial vs placeholder data

The initial and placeholder data options are two different ways to inform how the query needs to behave when no data is stored in cache.

The initial data option provides to the query the data (not partial) that's going to be shown initially in the application, skipping the loading state and caching its value, being useful whenever the data is somehow available already. However, as the stale time defaults to 0 milliseconds the query is going to be revalidated immediately, so to avoid this the following can be done:

1	 // Show initialTodos immediately, but won't refetch until another interaction event is encountered after 1000 ms
2	 const result = useQuery({
3	   queryKey: ['todos'],
4	   queryFn: () => fetch('/todos'),
5	   initialData: initialTodos,
6	   staleTime: 60 * 1000, // 1 minute
7	   // This could be 10 seconds ago or 10 minutes ago
8	   initialDataUpdatedAt: initialTodosUpdatedTimestamp, // eg. 1608412420052
9	 })
10	 
11	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data
12	 

By comparing the difference between staleTime and initialDataUpdatedAt the query knows if the initial data is fresh or not and needs to be revalidated.

Another option is to pass a function as the initial data to the query. The function is going to be executed only once when the query is initialized.

1	 const result = useQuery({
2	   queryKey: ['todo', todoId],
3	   queryFn: () => fetch(`/todos/${todoId}`),
4	   staleTime: 60 * 1000, // 1 minute
5	   initialData: () => queryClient.getQueryData(['todos'])?.find((d) => d.id === todoId), // Use a todo from the 'todos' query as the initial data for this todo query
6	   initialDataUpdatedAt: () => queryClient.getQueryState(['todos'])?.dataUpdatedAt // Get the freshness of the 'todos' query
7	 })
8	 

One more important thing illustrated in the example above is how the cache from other queries can be used to populate another's query cache with the initial data option. By using the query client and its built-in methods getQueryData and getQueryState it's possible to access another's query data and state, and use them however the application needs.

On the other hand, there's the placeholder data option which allows the queries to behave as if they already have data, similar to the initial data option, but it does not persist the data to the cache. It's useful when there's enough partial or fake data that can be displayed beforehand while the actual data is fetched in background.

1	 function Todos() {
2	   const result = useQuery({
3	     queryKey: ['todos'],
4	     queryFn: () => fetch('/todos'),
5	     placeholderData: placeholderTodos,
6	   })
7	 }
8	 
9	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/placeholder-query-data
10	 

Additionally, the function version of the placeholder data option have access to the previous data of that query, which means it's possible to avoid showing loading states while fetching with dynamic queries like: fetching something by its id, or a paginated query.

1	 const result = useQuery({
2	   queryKey: ['todos', id],
3	   queryFn: () => fetch(`/todos/${id}`),
4	   placeholderData: (previousData, previousQuery) => previousData,
5	 })
6	 
7	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/placeholder-query-data
8	 

or simply:

1	 import { useQuery, keepPreviousData } from '@tanstack/react-query'
2	 
3	 const result = useQuery({
4	   queryKey: ['todos', id],
5	   queryFn: () => fetch(`/todos/${id}`),
6	   placeholderData: keepPreviousData
7	 })
8	 

Prefetching data

Prefetching data basically means fetching resources that will be possibly needed before they are actually needed. There are some different prefetching patterns:

  • Inside components
  • Inside event handlers
  • Via router (more about this later)

When rendering components there are often cases where a child component that needs to fetch some piece of data needs its parent query to be finished loading so it can be rendered and then start fetching the data it actually needs. This generates what's called request waterfall and can lead to poor user experience and perceived performance. For example:

1	 function Article({ id }) {
2	   const { data: articleData, isPending } = useQuery({
3	     queryKey: ['article', id],
4	     queryFn: getArticleById,
5	   })
6	 
7	   if (isPending) {
8	     return 'Loading article...'
9	   }
10	 
11	   return (
12	     <>
13	       <ArticleHeader articleData={articleData} />
14	       <ArticleBody articleData={articleData} />
15	       <Comments id={id} />
16	     </>
17	   )
18	 }
19	 
20	 function Comments({ id }) {
21	   const { data, isPending } = useQuery({
22	     queryKey: ['article-comments', id],
23	     queryFn: getArticleCommentsById,
24	   })
25	 
26	   ...
27	 }
28	 
29	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
30	 

This generates the following waterfall:

1	 1. |> getArticleById()
2	 2.   |> getArticleCommentsById()
3	 
4	 Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
5	 

An approach to navigate this is to prefetch the data like:

1	 function Article({ id }) {
2	   const { data: articleData, isPending } = useQuery({
3	     queryKey: ['article', id],
4	     queryFn: getArticleById,
5	   })
6	 
7	   // Prefetch
8	   useQuery({
9	     queryKey: ['article-comments', id],
10	     queryFn: getArticleCommentsById,
11	     // Optional optimization to avoid rerenders when this query changes:
12	     notifyOnChangeProps: [],
13	   })
14	 
15	   if (isPending) {
16	     return 'Loading article...'
17	   }
18	 
19	   return (
20	     <>
21	       <ArticleHeader articleData={articleData} />
22	       <ArticleBody articleData={articleData} />
23	       <Comments id={id} />
24	     </>
25	   )
26	 }
27	 
28	 function Comments({ id }) {
29	   const { data, isPending } = useQuery({
30	     queryKey: ['article-comments', id],
31	     queryFn: getArticleCommentsById,
32	   })
33	 
34	   ...
35	 }
36	 
37	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
38	 

Now the waterfall looks like:

1	 1. |> getArticleById()
2	 1. |> getArticleCommentsById()
3	 
4	 Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
5	 

Also, it's possible to prefetch data inside the queryFn function of a query and for this it's possible to use the query client:

1	 const queryClient = useQueryClient()
2	 
3	 const { data: articleData, isPending } = useQuery({
4	   queryKey: ['article', id],
5	   queryFn: (...args) => {
6	     queryClient.prefetchQuery({
7	       queryKey: ['article-comments', id],
8	       queryFn: getArticleCommentsById,
9	     })
10	 
11	     return getArticleById(...args)
12	   },
13	 })
14	 
15	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
16	 

Note that if the component is being suspended the application needs to use usePrefectQuery and useSuspenseQuery. For more information about this:

Another way to prefetch data is by identifying the user's intentions with event handlers and then fetching the data that's possibly going to be needed. It's possible to do this by using the prefetchQuery method.

This method only populates the query cache and returns nothing.

1	 function ShowDetailsButton() {
2	   const queryClient = useQueryClient()
3	 
4	   const prefetch = () => {
5	     queryClient.prefetchQuery({
6	       queryKey: ['details'],
7	       queryFn: getDetailsData,
8	       // Prefetch only fires when data is older than the staleTime,
9	       // so in a case like this you definitely want to set one
10	       staleTime: 60000,
11	     })
12	   }
13	 
14	   return (
15	     <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
16	       Show Details
17	     </button>
18	   )
19	 }
20	 
21	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
22	 

By doing this anytime the user hovers over or focus on the element, the data is going to be prefetched. It's important to set a stale time in this cases so the application does not make a lot of unnecessary requests to the server.

Ensure query data

Tanstack query provides a method to populate the query client cache called ensureQueryData that can be used to make sure the data of a given query is available when needed. Ensuring the query data is there means the method is going to check if the query being made has cached data that's not stale and return it, if the query does not have cached data yet the ensureQueryData method calls the api internally, saves its response to the cache and returns the data.

1	 const data = await queryClient.ensureQueryData({ queryKey, queryFn })
2	 
3	 // Source: https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientensurequerydata
4	 

This is useful when the application needs to prefetch data that may have been cached already. For example, inside routes loaders as the page could be accessed directly via its URL and this would require all the data to be loaded from scratch inside the loader, or the page could be open by the user navigating to it using a link that may prefetch the data of its target page when the mouse is over it, which means the route loader does not need to prefetch the data again.

Infinite queries

It's also possible to perform cursor-based queries with infinite loading using Tanstack Query. Using the useInfiniteQuery, useSuspenseInfiniteQuery and usePrefetchInfiniteQuery hooks it's possible to infinite-load any cursor-based query in any situation the application may face.

1	 import { useInfiniteQuery } from '@tanstack/react-query'
2	 
3	 function Projects() {
4	   const fetchProjects = async ({ pageParam }) => {
5	     const res = await fetch('/api/projects?cursor=' + pageParam)
6	     return res.json()
7	   }
8	 
9	   const {
10	     data,
11	     error,
12	     fetchNextPage,
13	     hasNextPage,
14	     isFetching,
15	     isFetchingNextPage,
16	     status,
17	   } = useInfiniteQuery({
18	     queryKey: ['projects'],
19	     queryFn: fetchProjects,
20	     initialPageParam: 0,
21	     getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
22	   })
23	 
24	   return status === 'pending' ? (
25	     <p>Loading...</p>
26	   ) : status === 'error' ? (
27	     <p>Error: {error.message}</p>
28	   ) : (
29	     <>
30	       {data.pages.map((group, i) => (
31	         <React.Fragment key={i}>
32	           {group.data.map((project) => (
33	             <p key={project.id}>{project.name}</p>
34	           ))}
35	         </React.Fragment>
36	       ))}
37	       <div>
38	         <button
39	           onClick={() => fetchNextPage()}
40	           disabled={!hasNextPage || isFetchingNextPage}
41	         >
42	           {isFetchingNextPage
43	             ? 'Loading more...'
44	             : hasNextPage
45	               ? 'Load More'
46	               : 'Nothing more to load'}
47	         </button>
48	       </div>
49	       <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
50	     </>
51	   )
52	 }
53	 
54	 // Source: https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries
55	 

Also, it's possible to prefetch the data of infinite queries inside effects, callbacks and outside React using:

For more details on the infinite query options:

Infinite query options

Devtools

Tanstack Query comes with a built-in component that helps developers debug the queries and mutations being made by the application, with this component it's possible to see the state of the queries and mutations, as well as manipulate data and trigger events manually. It's widely recommended to have this installed in the application. The state of a query can be:

  • Fresh
  • Fetching
  • Paused
  • Stale
  • Inactive

Devtools

Routes loaders

Most modern client-side routers allows the applications to have a loader function that executes outside the framework being used, meaning that the loader is going to be executed before the framework render the page.

In React Router Dom since version 6, it's possible to setup a loader for each individual route as the following:

1	 createBrowserRouter([
2	   {
3	     path: "/teams/:teamId",
4	     loader: ({ params }) => {
5	       return fakeGetTeam(params.teamId)
6	     }
7	   }
8	 ])
9	 
10	 // Source: https://reactrouter.com/en/main/route/loader
11	 

By using loaders it's possible to retrieve data from anywhere like external services, and make it available to the component being rendered in this loaders route.

1	 function loader({ request }) {
2	   const url = new URL(request.url)
3	   const searchTerm = url.searchParams.get("q")
4	   return searchProducts(searchTerm)
5	 }
6	 
7	 // Source: https://reactrouter.com/en/main/route/loader
8	 

Then it's possible to access the by using the useLoaderData hook:

1	 function SomeRoute() {
2	   const data = useLoaderData()
3	   // { some: "thing" }
4	 }
5	 
6	 // Source: https://reactrouter.com/en/main/route/loader
7	 

Another important thing to notice is that when lazy-loading components, React Router Dom is going to call the lazy and the loader functions at the same time, so by the time the browser finishes downloading the javascript to render the page it's highly possible the loader has finished run meaning the data will be available almost instantly.

Integrating routes loaders and Tanstack Query

Even though it's possible to use the router capabilities alone to make data available early in the application, it's even better to combine both and take advantage of their strengths.

That said, the application can leverage the way loaders work and the moment they are run in the life cycle to prefetch resources using Tanstack Query.

1	 import { createBrowserRouter } from 'react-router-dom'
2	 import { QueryClient } from '@tanstack/react-query'
3	 import { getUsers } from '@api/get-users'
4	 
5	 const queryClient = new QueryClient()
6	 
7	 createBrowserRouter([
8	   {
9	     path: "/some-route",
10	     loader: async () => {
11	       // Makes sure every query that has ['users'] as query key has cached data
12	       await queryClient.ensureQueryData({
13	         queryKey: ['users'],
14	         queryFn: getUsers
15	       })
16	 
17	 	  // Needs to return something, even if it's null
18	       return null
19	     }
20	   }
21	 ])
22	 

Good practices and performance

There are some good practices that are advised to be followed when using Tanstack Query. By making sure these practices are followed it's most likely the perceived performance, maintainability and UX of the application is going to be kept in good level.

Awaiting promises

Regarding the performance of prefetching queries, it's important to pay attention to blocking code as sometimes awaiting for a promise to be resolved is not required for the application.

1	 // Non-blocking code
2	 function prefetch() {
3	   queryClient.prefetchQuery(queryOptions)
4	 }
5	 
6	 // Blocking code
7	 async function prefetch() {
8	   await queryClient.prefetchQuery(queryOptions)
9	 }
10	 

There's nothing else after the prefetchQuery call so it's not necessarily needed to await the promise unless it's intentional.

Another important thing to keep in mind is to use promise concurrency when necessary. Sometimes a function needs to prefetch more than one query at a time and this can lead to poor perceived performance and big loading times if the promises are not resolved concurrently.

1	 // Bad - await for each promise to be resolved
2	 function prefetch() {
3	   await queryClient.prefetchQuery(queryOptions1)
4	   await queryClient.prefetchQuery(queryOptions2)
5	   await queryClient.prefetchQuery(queryOptions3)
6	 }
7	 
8	 // Good - resolve promises concurrently
9	 async function prefetch() {
10	   await Promise.all(
11	     queryClient.prefetchQuery(queryOptions1),
12	     queryClient.prefetchQuery(queryOptions2),
13	     queryClient.prefetchQuery(queryOptions3),
14	   )
15	 }
16	 

Note that sometimes a query may need a piece of information that returns from another query so prefetching the query earlier or persisting the important values in places that can be accessed globally like the local storage or the URL may be good strategies.

Reusability and isolation

An extra good practice is to isolate queries in functions so that they can be reused easily in different places. Also, it's important to remember to expose the important things while keeping a good level of encapsulation. Tanstack Query has a helper function called queryOptions that facilitates this:

1	 // users-query-options.ts
2	 import { getUsers } from "@/services/users-service/users-service"
3	 import { keepPreviousData, queryOptions } from "@tanstack/react-query"
4	 
5	 export const usersQueryOptions = queryOptions({
6	   queryKey: ["users"],
7	   queryFn: getUsers,
8	   placeholderData: keepPreviousData, // Avoids laggy data transitions
9	 })
10	 
11	 // query.types.ts
12	 export type QueryParams<TData, TReturn> = {
13	   select?: (data: TData) => TReturn // Enables query mapping
14	   initialData?: TData | (() => TData)
15	 }
16	 
17	 // use-users.ts
18	 import { usersQueryOptions } from "@/lib/react-query/users-query-options/users-query-options"
19	 import { useQuery } from "@tanstack/react-query"
20	 
21	 import type { QueryParams } from "@/lib/react-query/query.types"
22	 import type { ApiUser } from "@/services/users-service/users-service"
23	 
24	 export function useUsers<TReturn = ApiUser[]>({
25	   select,
26	   initialData,
27	 }: QueryParams<ApiUser[], TReturn> = {}) {
28	   const { data, isLoading, isFetching, isPending, isError, error, refetch } = useQuery({
29	     ...usersQueryOptions,
30	     select,
31	     initialData,
32	   })
33	 
34	   return {
35	     data,
36	     isLoading,
37	     isFetching,
38	     isPending,
39	     isError,
40	     error,
41	     refetch,
42	   }
43	 }
44	 

The custom React hook above is a good strategy to isolate queries. It exposes the select and initialData methods and returns only a subset of the useQuery return object. Note how the hook is typed using generics, allowing the returned data type to be inferred from the return of the select method which defaults to the original return type, this is extremely important for type-safety.

Usage of the custom hook:

1	 // users.tsx
2	 import { useUsers } from "@/hooks/use-users/use-users"
3	 import { UserMapper } from "@/mappers/user-mapper/user-mapper"
4	 
5	 const userMapper = UserMapper.create() // stable set of methods
6	 
7	 export function Users() {
8	   const { data: users = [] } = useUsers() // data is inferred as an array of api users
9	   const { data: mappedUsers = [] } = useUsers({ select: userMapper.mapMany }) // data is inferred as an array of mapped users;
10	   const { data: usersCount = 0 } = useUsers({ select: userMapper.count }) // data is inferred as a number;
11	 
12	   return ...
13	 }
14	 

Additionally, it's important to use stable functions on the select and initialData parameters as this optimizes the rendering process. Stable functions can be functions created or imported from outside React's context (hooks, components, etc...), or functions that were memoized by useCallback. For more information see:

Render optimizations

Query client

Also, apart from the things mentioned above it's important to set default values for important options in the query client instance.

1	 import { QueryClient } from "@tanstack/react-query";
2	 
3	 const queryClient = new QueryClient({
4	   defaultOptions: {
5	     queries: {
6	       retry: false, // Set this only in specific queries if needed
7	       refetchOnWindowFocus: false,
8	       staleTime: 1000 * 60, // 1 minute for every query, unless it's overriden by the query itself
9	     },
10	   }
11	 });
12	 

Linting

Tanstack query provides a linting tool to enforce the best practices while using it. For more details on how to se it up see:

ESLint plugin query