Server State Management with React Query

The Problem

Application state falls into the following two categories:

  • Server state (stored on the server and fetched to the client for quick access).
  • Client/UI state (state that controls parts of the UI).

A common challenge faced when building React apps is managing the data fetched from the server (server state). Libraries like Redux, MobX and others are commonly used for managing application state. The mechanism behind most of these is that they expose all our application state as one global state object.

Since the server side state has two sources of truth, which are both the frontend and backend, it needs to constantly synchronize with the data on the backend to make sure it's not out of date.  This server state needs to be managed separately from client state and requires a tool that handles caching, knows when data is stale, performs background updates and also performance optimizations like pagination and lazy-loading. These are very difficult to do effectively on global state management libraries like Redux.


How React-Query solves the problem?

React Query is one of the best libraries for managing server state. It has a very simple API and only two hooks. It takes care of a lot of the requirements you'll need for fetching, caching, synchronizing and updating server state and it lets you do all this with just few a lines of code.

This is a basic example gotten from the official documentation, where React Query is being used to fetch data. Looks simple enough right?

import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
 
 const queryClient = new QueryClient()
 
 export default function App() {
   return (
     <QueryClientProvider client={queryClient}>
       <Example />
     </QueryClientProvider>
   )
 }
 
 function Example() {
   const { isLoading, error, data } = useQuery('repoData', () =>
     fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res =>
       res.json()
     )
   )
 
   if (isLoading) return 'Loading...'
 
   if (error) return 'An error has occurred: ' + error.message
 
   return (
     <div>
       <h1>{data.name}</h1>
       <p>{data.description}</p>
       <strong>👀 {data.subscribers_count}</strong>{' '}
       <strong>✨ {data.stargazers_count}</strong>{' '}
       <strong>🍴 {data.forks_count}</strong>
     </div>
   )
 }

Installation

You can install React Query from npm or yarn by running:

 $ npm i react-query
 # or
 $ yarn add react-query

A QueryClient can be used to interact with the cache. First, wrap your app with QueryClientProvider component to connect and provide a QueryClient to your application.

import React from 'react';
....
import { QueryClient, QueryClientProvider } from 'react-query'


ReactDOM.render(
    <QueryClientProvider client={queryClient}>
    <App />
    </QueryClientProvider>
  ,
  document.getElementById('root')
);

The Process

The React Query library has 2 important concepts we need to concern ourselves with:

  1. Queries
  2. Mutations

Each of the concepts have their signature hooks which needs to be called from a React component or custom hook in your application.

Queries

This makes use of the useQuery hook for fetching data. It's typically used for GET methods.

To subscribe to a query in your components or custom hooks, call the useQuery hook with at least:

  • A unique key for the query
  • A function that returns a promise that returns data or throws an error.
import { useQuery } from "react-query";

const { status, error, data } = useQuery(
  queryKey,
  async function queryFunction() {
    // The function to return a promise or throws an error. Typically houses the logic that makes the API request.
  }
);

The query key passed to the hook needs to be unique because it helps React Query to properly differentiate between different queries under the hood for proper refetching, caching, and sharing of your queries throughout your application.

 import { useQuery } from 'react-query'
 
 function App() {
   const results = useQuery('todos', fetchTodoList)
 }

The object returned by useQuery hook, which in the example above is named results, includes a field called status, which describes the state of the ongoing request. It's values can either be idle, loading, success or error.

Asides from the status field the hook also returns has the following important states that a query can be at a given time:

  • isLoading : This returns true when the query has no data and is currently fetching. This is also represented by status === 'loading'.
  • isError : This returns true when the query encountered an error. This is also represented by status === 'error'.
  • isSuccess : This returns true when the query was successful and data is available. This is also represented by status === 'success'
  • isIdle : This returns true when the query is currently disabled. This is also represented by status === 'idle'

Asides from those, more fields returned based on the state of the query include:

  • error : If the query is in an isError state, the details of the error is available via the error property.
  • data: If the query is in a success state, the data is available via the data property.
  • isFetching : In any state, if the query is fetching at any time (including background refetching) isFetching will be true.
function Todos() {
   const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList)
 
   if (isLoading) {
     return <span>Loading...</span>
   }
 
   if (isError) {
     return <span>Error: {error.message}</span>
   }
 
   // We can assume by this point that `isSuccess === true`
   return (
     <ul>
       {data.map(todo => (
         <li key={todo.id}>{todo.title}</li>
       ))}
     </ul>
   )
 }

Notes on Query Keys:

Asides being unique, query keys can take on any serializable value. They can be as simple as a string, or as complex as an array of many strings and nested objects. React Query uses the whole query key to identify the query.

// These are all different queries. Order of array items matter 
 useQuery(['todos', status, page], ...)
 useQuery(['todos', page, status], ...)
 useQuery(['todos', undefined, page, status], ...)
 
 // These are all the same queries. Order of keys in an object don't matter
 useQuery(['todos', { status, page }], ...)
 useQuery(['todos', { page, status }], ...)
 useQuery(['todos', { page, status, other: undefined }], ...)

Calling the a query with exactly the same query keys in two different components will cause React Query to intelligently return pre-existing data for that query.

It's advisable to include all variables you make use of in the query function, that change in your query key also, as opposed to invoking the function with parameters directly. This  helps React Query to know when to invalidate and refetch the query, much like how useEffect React hook re-runs your effect when the dependency array changes.

useQuery(['todos', status, page], fetchTodoList) 

// The query is automatically invalidated and refetched when either status or page change.

Mutations

This makes use of the useMutation hook. It's typically used for POST, PATCH, PUT, DELETE methods( methods that involve creating or updating or deleting data).

import { useMutation } from "react-query";

const mutation = useMutation(
  async function mutatorFunction(variables) {
    // Make the request with the variables
  }
);


const handleSubmit = async () => {
// The mutate function takes an object that it passes on to the mutator function
    await mutation.mutate({
      // data or variables being sent to the mutator function to carry out the request goes here
    }); 
};

Similar to queries, the object returned by useMutate hook includes a  status field and can be in one of the following states at a given time:

  • isLoading : This returns true when the  mutation is currently running. This is also represented by status === 'loading'.
  • isError :  This returns a value of true when the mutation encountered an error. This is also represented by status === 'error'.
  • isSuccess This returns true when the mutation was successful and mutation data is available. This is also represented by status === 'success'.
  • isIdle: This returns true when the mutation is currently idle or in a fresh/reset state. This is also represented by status === 'idle'.

Asides from those, more fields returned based on the state of the query include:

  • error - If the mutation is in an isError state, the details of error is available via the error property.
  • data - If the mutation is in a success state, the data is available via the data property.
 function App() {
   const mutation = useMutation(newTodo => axios.post('/todos', newTodo))
 
   return (
     <div>
       {mutation.isLoading ? (
         'Adding todo...'
       ) : (
         <>
           {mutation.isError ? (
             <div>An error occurred: {mutation.error.message}</div>
           ) : null}
 
           {mutation.isSuccess ? <div>Todo added!</div> : null}
 
           <button
             onClick={() => {
               mutation.mutate({ id: new Date(), title: 'Do Laundry' })
             }}
           >
             Create Todo
           </button>
         </>
       )}
     </div>
   )
 }

More Examples

Passing and retrieving parameters in queries.
function MyComponent() {
  const { isLoading, isError, error, data } = useQuery(
    ["students", {id: 1}],
    fetchStudent
  );

  if (isLoading) {
    return <span>Loading...</span>
  }

  if (isError) {
    return <span>Error: {error.message}</span>
  }

  return (
    <div>
       <div><b>The Student's Details are: </b></div>
         <div>Name: {data?.name}</div>  
         <div>Level: {data?.level}</div>  
    </div>
  );
}
const fetchStudent = async ({ queryKey }) => {
  const [_key, { id }] = queryKey; // Extract the id from the queryKey

  try {
    const response = await axios.get(`students/${id}`);
    return response.data;
  } catch (e) {
    throw new Error(e);
  }
};
Performing Side Effects for Mutations

The useMutation hook lets you perform side-effects at different stages in the mutation lifecycle.

function MyComponent() {
  const createPost = useMutation(addPost, {
    onSuccess: (data) => {
      console.log("This would run if the request is successful", data)
    },
    onError: (error) => {
      console.log("This would run if the request is unsuccessful", error)
    }
  })

  const handleSubmit = async () => {
    try {
      createPost.mutate({
        title: 'foo',
        body: 'bar',
        userId: 1,
      })
    } catch (e) {
      // display error message
    }
  }

  return (   
    <button onClick={()=>handleSubmit()}>Create New Post</button>    
  );
}
const addPost = async (data) => {
  try {
    const response = await axios.post(`/posts`, data);
    return response.data;
  } catch (e) {
    throw new Error(e);
  }
};
Returning Promises from mutations

To get a promise which will resolve on success or throw on an error after performing a mutation use mutateAsync instead of mutate.

function MyComponent() {
  const createPost = useMutation(addPost, {
    onSuccess: () => {
      console.log("This would run if the request is successful")
    },
    onError: () => {
      console.log("This would run if the request is unsuccessful")
    }
  })

  const handleSubmit = async () => {
    try {
      const data= await createPost.mutateAsync({
        title: 'foo',
        body: 'bar',
        userId: 1,
      })
      console.log(data)
    } catch (e) {
      // display error message
    }
  }

  return (   
    <button onClick={()=>handleSubmit()}>Create New Post</button>    
  );
}
Invalidating Queries

QueryClient has an invalidateQueries method that lets you intelligently mark queries as stale and potentially refetch them too.

 import { useMutation, useQueryClient } from 'react-query'
 
 const queryClient = useQueryClient()
 
 ....

const createPost = useMutation(addPost, {
  onSuccess: (data) => {
    console.log("This would run if the request is successful", data);

    // invalidates and refetches queries with the key of 'posts'and 'students'
    queryClient.invalidateQueries("posts");
    queryClient.invalidateQueries("students");
  },
  onError: (error) => {
    console.log("This would run if the request is unsuccessful", error);
  },
});

There are also a handful of different methods available on the QueryClient interface for interacting with the cache and can be found here :

https://react-query.tanstack.com/reference/QueryClient#queryclient