React query - modern way of working with async data

September 29th, 2022 - 20 minutes read

When building a modern application, it is quite an often scenario that you will need to access some external API, a.k.a, async data flow. Main issue with async data, is that we do not know, when we will get a response from external source. JavaScript by itself only provides a way how to make a HTTP call, but developer has to think how to tackle all the challenges that comes together with it.

Quick intro into a problem

We want to display users list which comes from BE API. To do this with React, we create a simple UserList component which lists user names. It also has useEffect which is being triggered on mount, in which we use Axios to call HTTP endpoint to retrieve user data. On successful response we store data in state variable, this triggers a rerender and fetched list is now visible to our users.

1const UserList = () => {
2  const [data, setData] = useState<User[]>([]);
3
4  useEffect(() => {
5    axios.get<User[]>('/users').then((res) => {
6      setData(res.data);
7    });
8  }, []);
9
10  return (
11    <ul>
12      {data.map((user) => (
13        <li key={user.id}>{user.name}</li>
14      ))}
15    </ul>
16  );
17};
18

But we have an issue!

While data is being loaded, we see blank screen. To solve this, let's add some code to handle loading state.

1const UserList = () => {
2  const [data, setData] = useState<User[]>([]);
3  const [isLoading, setIsLoading] = useState(true);
4
5  useEffect(() => {
6    setIsLoading(true);
7
8    axios.get<User[]>('/users').then((res) => {
9      setData(res.data);
10      setIsLoading(false);
11    });
12  }, []);
13
14  if (isLoading) {
15    return <div>Loading...</div>;
16  }
17
18  return (
19    <ul>
20      {data.map((user) => (
21        <li key={user.id}>{user.name}</li>
22      ))}
23    </ul>
24  );
25};
26

This now looks better, but what happens when our endpoints throw an error?

We get stuck in loading state. So let's add another state variable for storing request error state. Now it should work as expected.

1const UserList = () => {
2  const [data, setData] = useState<User[]>([]);
3  const [isLoading, setIsLoading] = useState(true);
4  const [isError, setIsError] = useState(false);
5
6  useEffect(() => {
7    setIsLoading(true);
8
9    axios
10      .get<User[]>('/users')
11      .then((res) => {
12        setData(res.data);
13      })
14      .catch(() => {
15        setIsError(true);
16      })
17      .finally(() => {
18        setIsLoading(false);
19      });
20  }, []);
21
22  if (isError) {
23    return <div>Something went wrong</div>;
24  }
25
26  if (isLoading) {
27    return <div>Loading...</div>;
28  }
29
30  return (
31    <ul>
32      {data.map((user) => (
33        <li key={user.id}>{user.name}</li>
34      ))}
35    </ul>
36  );
37};
38

But we now have 3 state variables, and it would be nice join them to single object variable, because they are all related and usually change together.

1type QueryState = {
2  data: User[];
3  isLoading: boolean;
4  isError: boolean;
5};
6
7const UserList = () => {
8  const [{ data, isLoading, isError }, setQueryState] = useState<QueryState>({
9    data: [],
10    isLoading: true,
11    isError: false,
12  });
13
14  useEffect(() => {
15    setQueryState({ data: [], isLoading: true, isError: false });
16
17    axios
18      .get<User[]>('/users')
19      .then((res) => {
20        setQueryState({ data: res.data, isLoading: false, isError: false });
21      })
22      .catch(() => {
23        setQueryState({ data: [], isLoading: false, isError: true });
24      });
25  }, []);
26
27  if (isLoading) {
28    return <div>Loading...</div>;
29  }
30
31  if (isError) {
32    return <div>Something went wrong</div>;
33  }
34
35  return (
36    <ul>
37      {data.map((user) => (
38        <li key={user.id}>{user.name}</li>
39      ))}
40    </ul>
41  );
42};
43

We did some refactoring, but now we notice, that our type for QueryState is not exactly correct, because it allows us to have impossible state, e.g. both isLoading and isError can be true.

Impossible states and how to solve them with discriminated unions by itself is quite an extensive topic, so I will not need dive into this. In short, we expect that TypeScript would help us in this case by limiting possible states count.

So lets fix this by splitting into type union. Now we have 3 states - loading, loaded and error.

So, at last, functionality is working and our code is type safe.

1type LoadingQueryState = {
2  data: undefined;
3  isLoading: true;
4  isError: false;
5};
6
7type LoadedQueryState = {
8  data: User[];
9  isLoading: false;
10  isError: false;
11};
12
13type ErrorQueryState = {
14  data: undefined;
15  isLoading: false;
16  isError: true;
17};
18
19type QueryState = LoadingQueryState | LoadedQueryState | ErrorQueryState;
20
21const UserList = () => {
22  const [{ data, isLoading, isError }, setQueryState] = useState<QueryState>({
23    data: undefined,
24    isLoading: true,
25    isError: false,
26  });
27
28  useEffect(() => {
29    setQueryState({ data: undefined, isLoading: true, isError: false });
30
31    axios
32      .get<User[]>('/users')
33      .then((res) => {
34        setQueryState({ data: res.data, isLoading: false, isError: false });
35      })
36      .catch(() => {
37        setQueryState({ data: undefined, isLoading: false, isError: true });
38      });
39  }, []);
40
41  if (isLoading) {
42    return <div>Loading...</div>;
43  }
44
45  if (isError) {
46    return <div>Something went wrong</div>;
47  }
48
49  return (
50    <ul>
51      {data.map((user) => (
52        <li key={user.id}>{user.name}</li>
53      ))}
54    </ul>
55  );
56};
57

So we could say that's it, we did a great job!

Until...

We get a new requirement - use same data to display a similar employee list. We come back to previous UserList implementation. We already have all users fetching logic.

1const UserList = () => {
2  const { data, isLoading, isError } = useUsersQuery();
3
4  if (isLoading) {
5    return <div>Loading...</div>;
6  }
7
8  if (isError) {
9    return <div>Something went wrong</div>;
10  }
11
12  return (
13    <ul>
14      {data.map((user) => (
15        <li key={user.id}>{user.name}</li>
16      ))}
17    </ul>
18  );
19};
20

So let's just create new custom hook named "useUsersQuery", extract fetch logic, and we will be able to use it in both places.

After that we create a new EmployeeList component which uses the same hook to get the data.

1const EmployeeList = () => {
2  const { data, isLoading, isError } = useUsersQuery();
3
4  if (isLoading) {
5    return <div>Loading...</div>;
6  }
7
8  if (isError) {
9    return <div>Something went wrong</div>;
10  }
11
12  return (
13    <ul>
14      {data.map((user) => (
15        <li key={user.id} style={{ color: 'blue' }}>
16          {user.name}
17        </li>
18      ))}
19    </ul>
20  );
21};
22

No code duplication, great success again.

But later we open Developer Tools, go to Network tab, and we see 2 requests instead of 1. 😢

So usual solution to such problem would be to lift state up to parent component and pass it to both lists.

1const App = () => {
2  const usersQuery = useUsersQuery();
3
4  return (
5    <main>
6      <UserList {...usersQuery} />
7      <EmployeeList {...usersQuery} />
8    </main>
9  );
10};
11

Redux

But we might need to pass multiple levels deeper, so usual solution to this is Redux. If some of you are not familiar, Redux is a global state management library. A lot of projects are using it, so lets use it as well.

1import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
2
3const fetchUsers = createAsyncThunk('users/fetch', async () => {
4  const response = await axios.get<User[]>('/users');
5  return response.data;
6});
7
8const usersSlice = createSlice({
9  name: 'users',
10  initialState: { data: undefined, isLoading: true, isError: false },
11  reducers: {},
12  extraReducers: (builder) => {
13    builder.addCase(fetchUsers.pending, (state, action) => {
14      state.data = undefined;
15      state.isLoading = true;
16      state.isError = false;
17    });
18
19    builder.addCase(fetchUsers.fulfilled, (state, action) => {
20      state.data = action.payload;
21      state.isLoading = false;
22      state.isError = false;
23    });
24
25    builder.addCase(fetchUsers.rejected, (state, action) => {
26      state.data = undefined;
27      state.isLoading = false;
28      state.isError = true;
29    });
30  },
31});
32

This is pretty much how it would look like using new Redux Toolkit with createAsyncThunk helper. But the code would look pretty much similar with Thunk middleware or by using Redux Saga.

1const UserList = () => {
2  const { data, isLoading, isError } = useSelector(state => state.users);
3
4  if (isLoading) {
5    return <div>Loading...</div>;
6  }
7
8  if (isError) {
9    return <div>Something went wrong</div>;
10  }
11
12  return (
13    <ul>
14      {data.map((user) => (
15        <li key={user.id}>{user.name}</li>
16      ))}
17    </ul>
18  );
19};
20

In each component we get the data from global state using selectors.

1const App = () => {
2  const dispatch = useDispatch();
3
4  useEffect(() => {
5    dispatch(fetchUsers())
6  }, [dispatch])
7
8  return (
9    <main>
10      <UserList />
11      <EmployeeList />
12    </main>
13  );
14};
15

And this is how the root App component would look like. We get Redux dispatch function, and in useEffect we dispatch an action to trigger data fetch So I guess lots of you are familiar with this code.

Problems with Redux

But it has few problems:

  1. We introduce lots of concepts like dispatch, selector, thunks, action creators, slices, etc.
  2. The thing why everyone loves Redux - boilerplate. We have over 50 lines of code just to handle single endpoint fetching, and it is still does not cover lots of cases.
  3. The connection between fetching and data is gone. Now App component simply dispatches event, and child components subscribe to store changes.
    If I would want to remove UserList component, I also have to know that I have to delete users fetching part from App component as well. But maybe it is used in EmployeeList component as well. So can I delete it or not? It introduces lots of mental overhead even in such a simple case.

After seeing all these problems, I started to think that there has to be a better way how to do it.

In search for salvation

More than 2 years ago I started a new project and saw a lot of hype on Twitter about new library called "React query". You know we are developers, we are obsessed with new shiny things 😈.

So I have decided to give it a try. It had almost identical syntax as Apollo client hooks, but had much better caching approach. It also was not tied to GraphQL syntax, so that was also a bonus, because our BE team preferred REST approach. So we immediately feel in love.

Of course our old partner redux was not so happy about this...

React Query intro

React query idea is that it separates client and server states, because they have completely different challenges, and focus on server state. After migrating to React Query, we noticed that about 90% of our previous global state came from server, and just 10% was purely client side.

This is the main reason why teams decide to replace Redux with some simpler alternatives like Context / Jotai / Zustand.

What challenges do we face when working with async data? It can be:

You can see that by each new feature our good old friend spaghetti code grows. So React Query said - enough, we have seen a lot of your async spaghetti monsters. It can offer all these features out of the box, with minimal configuration 🤩. Lets looks how to get started with it.

Query client

Everything starts with Query client. From developer perspective, usually we just create a QueryClient, pass global default values, and provide it to the QueryClientProvider. Later in code if we need to access it, we have nice utility hook called useQueryClient, which basically takes queryClient value from nearest context.

1const queryClient = new QueryClient({
2  defaultOptions: {
3    queries: {
4      retry: false,
5      refetchOnWindowFocus: false,
6    },
7  },
8});
9
10<QueryClientProvider client={queryClient}>
11  <Component>
12</QueryClientProvider>
13
14const Component = () => {
15  const queryClient = useQueryClient();
16};
17

Query

One of the main hooks provided by React query is an useQuery hook, which main purpose is to fetch data as soon as component mounts and to sync all new data when cache is updated.

We could say that it is kind of combination of Redux useSelector + dispatch on component mount. This way we have a connection, between data and it's fetching.

Another nice things, is that you no longer need to care about where to fetch data, because even if you call it multiple times, React Query will only make single request. So no more lifting state up approach.

The hook has 2 mandatory properties:

  • queryKey (has 2 purposes) - unique key + dependencies array;
  • queryFn - function which fetches data;

This hook also accepts a wide variety of other properties, which allow to customize the default behaviour. I will be using some of them later in the article.

When hook is used, it returns an object with all required information about the query and functions like refetch.

1const usersQuery = useQuery({
2  queryKey: ['users'],
3  queryFn: async () => {
4    const response = await axios.get<User[]>('/users');
5    return response.data;
6  },
7});
8
9const {
10  data,
11  isLoading,
12  isError,
13  status,
14  refetch
15} = usersQuery;
16

Mutation

When we want to make some request to modify something, we need to use useMutation hook. Basically it is usually just a wrapper around Axios call, which does few things:

  1. It provides variables like isLoading, data, to help us work with response information. That means we no longer have to track this state ourselves.
  2. When defining hook, we also have an option to specify various callback, like in this case onSuccess, where we can do some actions, e.g. show a toast message or do a query invalidation.
1const createUserMutation = useMutation(
2  async (payload: Pick<User, 'name'>) => {
3    const response = await axios.post<User[]>('/users', payload);
4    return response.data;
5  },
6  {
7    onSuccess: () => {
8      queryClient.invalidateQueries('users');
9    },
10  },
11);
12
13const {
14  data,
15  isLoading,
16  isError,
17  isSuccess,
18  mutateAsync
19} = createUserMutation;
20
21const onButtonClick = () => {
22  mutateAsync({ name: 'Tadas' });
23};
24

Paginated data queries

If we want to add pagination support it is also very easy.

We just need to add page param to queryKey, to identify that query should trigger new fetch.

We also have to update queryFn to pass page param to our BE.

And if we want to have nice UX, to it is recommended to set "keepPreviousData" property to true. As name implies, it leaves old data visible for user, until next page data has fetched. Without it, query would show empty screen for user until next page data has finished loading.

1const usersQuery = useQuery({
2  queryKey: ['users', page],
3  queryFn: async () => {
4    const response = await axios.get<User[]>('/users', {
5      params: { page }
6    });
7    return response.data;
8  },
9  keepPreviousData: true,
10});
11

Infinite scroll

React query has a hook for infinite scroll as well.

Main difference is that we need to supply additional param called getNextPageParam, where we need to inform React Query how to calculate next page param, because there might be various strategies for this. And as you can see, this nextPageParam is later being injected into queryFn params.

Overall, it is still paginated query, but you no longer have to track pages yourself, because React Query does it for you.

And this hook returns a bit different result where data is being grouped by page, so if we want to display as a single list, we need to do some flat mapping.

And this query also exposes required properties / functions to work with paginated data.

1const usersInfiniteQuery = useInfiniteQuery<PaginatedResult<User>>({
2  queryKey: ['users'],
3  queryFn: async ({ pageParam = 0 }) => {
4    const response = await axios.get<PaginatedResult<User>>('/users', {
5      params: { offset: pageParam }
6    });
7    return response.data;
8  },
9  getNextPageParam: ({ currentPage, offset, totalPages }) => {
10    if (currentPage + 1 < totalPages) {
11      return offset + 20;
12    }
13    return undefined;
14  },
15});
16
17const data = usersInfiniteQuery.data?.pages.flatMap((page) => page.results);
18
19if (!usersInfiniteQuery.isFetchingNextPage && usersInfiniteQuery.hasNextPage) {
20  usersInfiniteQuery.fetchNextPage();
21}
22

Dependant queries

Another usually encountered situation is where one query depends on another.

For example, if we want to fetch user organization we first have to fetch user. So first we make standard userQuery to get user information.

1const userQuery = useQuery({
2  queryKey: ['user'],
3  queryFn: async () => {
4    const response = await axios.get<User>('/user');
5    return response.data;
6  },
7});
8
9const userId = userQuery.data?.id;
10
11const organizationQuery = useQuery({
12  queryKey: ['organization', userId],
13  queryFn: async () => {
14    if (!userId) {
15      throw new Error('Missing userId');
16    }
17    const response = await axios.get<Organization>('/organization', { params: { userId } });
18    return response.data;
19  },
20  enabled: !!userId,
21});
22

After that we create another useQuery hook, which has user userId in its query key. So when userId changes, query will refetch.

We also want to execute query only when we have userId. For this we need to add enabled property. Enabled flag informs React Query to only execute queryFn when it evaluates to true. We have to go this way, because as you might, or might not know, React does not allow to render hooks conditionally.

Moving forward to queryFn, it simply makes a query to organization endpoint passing userId as a query param.

More tricky place is on line 14-16, where we need to add additional check, to make TypeScript happy, because it is not so smart to know that queryFn will not be executed if userId is missing.

Just needs a Promise

And one of the features which really blew my mind when I firstly discovered it, was that React Query does not care if you are making a request or not. The only requirement it has that query function has to return a Promise 🤯.

One of the cases where we can use it, is when some BE API endpoint is still under development, so we can ourselves create a Promise, which resolves mock data. I also added small setTimeout, to mimic a bit request delay, but it can be done without it. And later when BE will provides us the endpoint, we will just have to replace promise resolve with Axios call.

And as long as our mock data structure matches BE, we won't need to change anything else in app, because all functionality like loading/error states will be already in place 🤩.

1const usersQuery = useQuery({
2  queryKey: ['users'],
3  queryFn: async () => {
4    return new Promise((resolve) => {
5      setTimeout(() => {
6        resolve([{ id: 1, name: 'fake user' }]);
7      }, 500);
8    });
9  },
10});
11

Async Storage (React Native)

And this is another example how you can use another async source. For example in React Native you have library called Async Storage, which writes data to user device. It pretty much the same thing as localStorage in web, just all operations are asynchronous. That makes it a really good candidate for React Query.

Plus it solves another problem with async storage - that you do not have a way to listen when storage value was changed. But if we wrap it with React Query, all places are automatically notified, when value changes.

1import AsyncStorage from '@react-native-async-storage/async-storage';
2
3export const useStorageQuery = (storageKey: StorageKey) =>
4  useQuery({
5    queryKey: ['storage', storageKey],
6    queryFn: async () => {
7      const value = await AsyncStorage.getItem(storageKey);
8      return value || undefined;
9    },
10  });
11
12export const useSetStorageMutation = (storageKey: StorageKey) => {
13  const queryClient = useQueryClient();
14
15  return useMutation(
16    (value: string) => {
17      return AsyncStorage.setItem(storageKey, value);
18    },
19    {
20      onSuccess: () => {
21        queryClient.invalidateQueries(['storage', storageKey]);
22      },
23    },
24  );
25};
26

Invalidate queries

One of the most popular operations with QueryClient is invalidateQueries. Its purpose is to mark that some part of the cache is stale and needs to be refreshed. Invalidation function works mainly on queryKey. It accepts query key, or if we want more flexibility, we can pass custom function with predicate.

It is also very important to understand that invalidate is not the same as refresh! I like to call it smart refresh instead 😺. So here we have 2 cases:

  1. If there are any mounted useQuery hook with same key, it will trigger query refetch;
  2. If no query is mounted, then data will be marked as stale, and new fetch will be triggered only when some component mounts.
1queryClient.invalidateQueries(['users']);
2queryClient.invalidateQueries(['users', { status: 'ACTIVE' }]);
3queryClient.invalidateQueries({
4  predicate: (query) =>
5    query.queryKey[0] === 'users' &&
6    (
7     query.queryKey[1]?.status === 'ACTIVE' ||
8     query.queryKey[1]?.status === 'DISABLED'
9    ),
10});
11

Another interesting thing is that invalidation by default works as partial match.

1queryClient.invalidateQueries(['users']);
2queryClient.invalidateQueries(['posts']);
3queryClient.invalidateQueries(['users', 'posts']);
4queryClient.invalidateQueries(['users'], { exact: true });
5
6-----------------------------------------
7
8queryKey: ['users'],
9queryKey: ['posts'],
10queryKey: ['users', 'posts'],
11

So if we would try to invalidate users queries, it would invalidate exact match users and partial match users + posts.

If we would try to invalidate posts queries, it would invalidate just exact posts match. And natural question why not users + posts query? That is because React Query matches keys from the left side.

If we would try to invalidate users + posts, it would only invalidate that exact users + posts query.

And if we would want to target some exact queryKey, to avoid cases like with first invalidation, we can pass option object with values exact: true. This will exclude partial matches like line 10.

Modifying cache manually

Query client also has a method call setQueryData, for cases, when you want to manually modify cache state.

1export const useUsersQuery = () => {
2  return useQuery({
3    queryKey: ['users'],
4    queryFn: () => axios.get<User[]>('/users'),
5  });
6};
7
8export const useUserQuery = (id: User['id']) => {
9  return useQuery({
10    queryKey: ['users', id],
11    queryFn: () => axios.get<User>(`/user/${id}`),
12  });
13};
14

One of such cases could be when you have list query and details query. With current config if you would enter details page, you would have to wait until data is fetched from API. That is not a nice UX, because we already have all or partial user data in list query.

We can improve this by adding onSuccess callback to users list query, in which we would manually inject values to query cache, under same queryKey as our user details query.

This way user would see instantly data he saw in list screen, but React Query would do additional background request to fetch latest user info.

1export const useUsersQuery = () => {
2  return useQuery({
3    queryKey: ['users'],
4    queryFn: () => axios.get<User[]>('/users'),
5    onSuccess: (data) => {
6      data.forEach((user) => {
7        queryClient.setQueryData(['users', user.id], user);
8      });
9    },
10  });
11};
12
13export const useUserQuery = (id: User['id']) => {
14  return useQuery({
15    queryKey: ['users', id],
16    queryFn: () => axios.get<User>(`/user/${id}`),
17  });
18};
19

Performance optimisation

React query has a few performance optimisations that come out of them box.

Structural sharing

When React Query receives new data, it does deep comparison to see if data has really changed. And if data is identical, it keeps the old reference, so various hooks like useEffect, useMemo or useCallback would not be triggered.

1const oldData = { id: 1, name: 'James', age: 25 };
2const newData = { id: 1, name: 'James', age: 25 };
3
4const data = deepEquals(oldData, newDate) ? oldData: newDate
5

Tracked properties

Another nice optimisation is "notifyOnChangeProps". React Query by itself will track which properties are we using from query, and only then rerender component. So if you do not use e.g., isLoading flag, your component will not be informed about this field change. React query also gives a possibility to completely opt out of this optimisation by providing "all" value.

How does it work under the hood?

But let's dive deeper to understand how React Query works under the hood.

At the very beginning we have empty queries list. When a query hook mounts, it creates query entry in queryCache, adds itself to listeners array and makes a call to API endpoint.

Later, if another query hook mounts with the same key, it check in queryCache that request is already pending, so it will not make a new request, but will only subscribe to query listeners array.

When we get API response, React Query updates internal cache and notifies all active listeners.

After receiving a message that cache was updated, hooks rerender to reflect latest cache state.

When all same key query hooks are destroyed, they unsubscribe from listeners array, and React Query schedules garbage collection. If another query does not mount during scheduled time frame, cache data is removed.

State modelling

Many other libraries usually just create single response type object with many boolean properties which has no dependencies between each other (like initial implementation on top of article). But React Query gives a little more effort and splits possible states in TypeScript union, which later allows us to discriminate and narrow it down.

1type Result = LoadingResult | LoadingErrorResult | RefetchErrorResult | SuccessResult
2
3interface LoadingResult<TData, TError> {
4  data: undefined
5  error: null
6  isError: false
7  isLoading: true
8  isLoadingError: false
9  isRefetchError: false
10  isSuccess: false
11  status: 'loading'
12}
13
14interface LoadingErrorResult<TData, TError> {
15  data: undefined
16  error: TError
17  isError: true
18  isLoading: false
19  isLoadingError: true
20  isRefetchError: false
21  isSuccess: false
22  status: 'error'
23}
24
25interface RefetchErrorResult<TData, TError,> {
26  data: TData
27  error: TError
28  isError: true
29  isLoading: false
30  isLoadingError: false
31  isRefetchError: true
32  isSuccess: false
33  status: 'error'
34}
35
36interface SuccessResult<TData, TError> {
37  data: TData
38  error: null
39  isError: false
40  isLoading: false
41  isLoadingError: false
42  isRefetchError: false
43  isSuccess: true
44  status: 'success'
45}
46

Stale while revalidate

Now it's time for another topic, which is called to be one of the hardest in development industry overall - Caching.

React Query uses caching algorithm, which is called stale while revalidate. This concept is not unique to React Query, because I guess some of you have seen this on http cache control header.

1Cache-Control: max-age=604800, stale-while-revalidate=86400

The main idea is to split data into 3 possible states - fresh, stale and outdated.

  • Fresh is state which was just recently fetched, we can use it and there is not need to refetch it again. So let's say if we fetched it less than 1 minute ago. The possibility that data changed during that time is very low, so we optimise, and do not make any request to get new data.
  • Stale data was fetched some time ago, but it might still provide some value to user, but it would be good to refetch it in background. Let's say users was fetched 5 minutes ago. We understand that it might be a bit outdated, but we expect the differences to be very minimal, so we can use old data, but at the same data try to get the latest from the server.
  • Outdated is the one which was fetched long time ago, and would cause more harm if we would try to use it. Now let's imagine that we fetched user data 1 hour before. It is quite a long period, and a lot of stuff could have changed, so displaying such old data has practically no benefit. So we no longer use this data, and wait for new data from the server.

Flow diagram

So now let's draw a flow diagram which should help to explain this pattern better.

First of all, we mount a hook and check if response is cached. If we have no data in cache, we return empty state. At the same time we send a request, update the cache and return new fetched data. End of the flow.

If response is cached, we also need to check if it is outdated. If it is outdated, then we do exactly the same as in previous case.

If it is not outdated, then we immediately return cached data to the user, but at the same time we check if this data is stale. If it is stale, then we send a request in a background, which will update the cache and rerenders the component with latest data.

React query caching

For the remaining part of article we will be using this config - 1 minute stale time, what means that is we fetched data less than 1 minute ago, it is in fresh state. And we will use cache time 5 minutes, which says that if data is older than 5 minutes, it is outdated and we do not want to display it.

1const usersQuery = useQuery({
2  queryKey: ['users'],
3  staleTime: 60 * 1000, // 1 minute
4  cacheTime: 5 * 60 * 1000, // 5 minutes
5  queryFn: async () => {
6    const response = await axios.get<User[]>('/users');
7    return response.data;
8  },
9});
10

Empty cache flow

Let's try to visualise it from user point of view. It is a situation where we have an empty cache and are making the request to fetch data. User first sees a Loading screen and after data was loaded, React rerenders component with users list data.

Fresh data flow

We just got data 30 seconds ago, so if some other component would try to call usersQuery hook, it would simply enter success state with data from the cache. So user would see instant view, without any loading states, and it would not make any new request to BE API.

Stale data flow

We now have stale data. User again gets success state with data from cache, but at the same time query triggers a new call to BE API to fetch latest users data. Query exposes "isFetching" flag which we use to indicate if it is doing Background fetch. And once it gets a response, it updates the cache, and informs subscribed components to update its UI.

Edge configurations

Never cached data

If we would like to never cache data, but have all other nice stuff like deduping, loading states, etc, we can set both properties to 0.

1const usersQuery = useQuery({
2  queryKey: ['users'],
3  staleTime: 0,
4  cacheTime: 0,
5  queryFn: async () => {
6    const response = await axios.get<User[]>('/users.json');
7    return response.data;
8  },
9});
10

Forever cached data

If we would like the cache to never expire, we can set both properties to Infinity. This way it will be quite similar to Redux. This means that queries will only refetch when we manually invoke it, e.g., call invalidateQueries function.

1const usersQuery = useQuery({
2  queryKey: ['users'],
3  staleTime: Infinity,
4  cacheTime: Infinity,
5  queryFn: async () => {
6    const response = await axios.get<User[]>('/users.json');
7    return response.data;
8  },
9});
10

Recommendations

Create custom hooks

Create custom hooks. If you inline query directly, it becomes hard to reuse it. So at least I prefer to create service/queries file which exposes custom hook and also contains entities type.

1// 🚨 Avoid
2const UserList = () => {
3  const { data, isLoading, isError } = useQuery({
4    queryKey: ['users'],
5    queryFn: () => axios.get<User[]>('/users'),
6  })
7
8  return <List data={data} />
9};
10
11// ✅ Recommended
12const UserList = () => {
13  const { data, isLoading, isError } = useUsersQuery();
14
15  return <List data={data} />
16};
17
18export const useUsersQuery = () => {
19  return useQuery({
20    queryKey: ['users'],
21    queryFn: () => axios.get<User[]>('/users'),
22  });
23}
24

Query key factories

When queries count starts to grow, it becomes quite hard to track query keys because they are usually inlined as string. As you can see in current example, we have 2 places, where queryKey "users" is being used. If you change in one place, it is quite easy to mess up and forget to change in another.

So I would recommend here to create queryKey generation utility. This way you can all keys variations in single place. I know that it is not a silver bullet, but it adds some structure to keys.

1// 🚨️ Avoid
2export const useUsersQuery = () => {
3  return useQuery({
4    queryKey: ['users', 'list'],
5    queryFn: () => axios.get<User[]>('/users'),
6  });
7};
8
9export const useCreateUserMutation = () => {
10  const queryClient = useQueryClient();
11
12  return useMutation((data) => axios.post('/users', data), {
13    onSuccess: () => {
14      queryClient.invalidateQueries(['users']);
15    },
16  });
17};
18
19// ✅ Recommended
20const userKeys = {
21  all: ['users'] as const,
22  list: () => [...userKeys.all, 'list'] as const,
23  details: (id: User['id']) => [...userKeys.all, id] as const,
24};
25
26export const useUsersQuery = () => {
27  return useQuery({
28    queryKey: userKeys.list(),
29    queryFn: () => axios.get<User[]>('/users'),
30  });
31};
32
33export const useCreateUserMutation = () => {
34  const queryClient = useQueryClient();
35
36  return useMutation((data) => axios.post('/users', data), {
37    onSuccess: () => {
38      queryClient.invalidateQueries(userKeys.all);
39    },
40  });
41};
42

Avoid destructuring

Lastly, I recommend not to do a query destructuring.

When using single query per component, then it is no a big problem. But as soon as we get second query, we would have to rename each property to avoid name collision.

So at least we prefer to keep it as query object, because it gives a namespace for later usage and you do not have to think how to name variables.

Also before TypeScript 4.6 type narrowing was not working when using destructuring, so that was an additional plus to use this syntax (TypeScript improved their control flow analysis and it is now able track dependant parameters).

1// 🚨 Avoid
2const UserListAvoid = () => {
3  const { data: usersData, isLoading: usersIsLoading, isError: usersIsError } = useUsersQuery();
4  const { data: postsData, isLoading: postsIsLoading, isError: postsIsError } = usePostsQuery();
5
6  return (
7    <div>
8      <List data={usersData} isLoading={usersIsLoading} isError={usersIsError} />
9      <List data={postsData} isLoading={postsIsLoading} isError={postsIsError} />
10    </div>
11  );
12};
13
14// ✅ Recommended
15const UserListGood = () => {
16  const usersQuery = useUsersQuery();
17  const postsQuery = usePostsQuery();
18
19  return (
20    <div>
21      <List data={usersQuery.data} isLoading={usersQuery.isLoading} isError={usersQuery.isError} />
22      <List data={postsQuery.data} isLoading={postsQuery.isLoading} isError={postsQuery.isError} />
23    </div>
24  );
25};
26

Alternatives

SWR

SWR is similar library backed by Vercel, which which is also famous for creating Next.js, but it has 2 times lower downloads count. Feature wise it also misses some things, e.g. it does not have mutation wrapper. And from types perspective it does not support type narrowing.

RTK Query

Redux Toolkit also released a wrapper on its own, called RTK Query. But it still lacking features like infinite queries, like React Query has. It only works with BE endpoints, so it also limits possible usage.

Summary

React query is a great tool. It has really nice DX, it is fun to work with and it has superb TypeScript support. Also when you face some uncommon problem, React Query has a solution for that already. It also works with any async source, not just APIs, and that opens many possibilities how it can be used.

Overall, React query was that library, which brought biggest shift to how we work with async data. It does all the heavy lifting for us, while allowing us to focus on delivering business value.