React Query - You may not need Redux

Over the years, people have asked me to help them debug their redux/redux tool kit code. Oftentimes, these debug sessions always lead me to recommend React query because of its efficiency and effectiveness.

This often results in them not only adopting React Query but also reducing their codebase just by simply deleting redux code and replacing them with React query code.

In this article, I’ll introduce you to React Query and how to use it to manage your server state.

What is React Query?

According to the official documentation, React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing, and updating server state in your React applications a breeze.

Before we used React query to fetch and persist data in our app, we have to write our fetch request, call it in a useEffect function, manually write a loading state, get this data and save it to our state/ global state either by using Redux or React context API before using this piece of data. But with React query, we just have to write this const todo = useQuery(‘todo’, todoFetchRequest) and we will get access to all the goodies React query has to offer, which include:

What is the Client and Server state?

From the definition of React query above, there is mention of Server state, which might be a new term for some people.

Server states are pieces of data that are persisted remotely in a database and would require some asynchronous API to fetch or update, whereas Client states are persisted in our app memory, and updating or fetching them is synchronous, they do not require any communication with the server.

Previously before React query, to work with server state, we had to fetch the data and turn it into a client state before making use of it, but with react query, that extra step is removed as react query handles that and also tries to make the server state as fresh as possible (consistent) with the data in the server by regular polling (refetching) at default interval either set by the user or the default interval

Setup

Install using yarn add @tanstack/react-query

import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { getTodos, postTodo } from '../my-api'

// Create a client
const queryClient = new QueryClient()

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <Todos />
    {/* The rest of your application */}

     // Add the dev tool
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Queries - useQuery

useQuery is a hook provided by react query to perform queries GET or POST. only use when your method gets/fetches data from your server, it takes a query key and the fetch function as arguments and returns const result = useQuery(['todos'], fetchTodoList)

The unique key (‘todos’), which can be anything you want usually used to describe the data you are fetching, and is used internally for refetching, caching, and sharing your queries throughout your application. The query result returned by useQuery is an object which contains all of the information about the query that you'll need for templating and any other usage of the data:

The result object contains a few critical states you'll need to be aware of to be productive.

  • isLoading or status === 'loading' - The query has no data yet
  • isError or status === 'error' - The query encountered an error
  • isSuccess or status === 'success' - The query was successful, and data is available
  • data - The data returned from the query
  • refetch - function used to refetch the query in case there is an error
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` which means the data is available
  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

mutation - useMutation

useMutation is used to perform the create/update/delete operations on the server, it is used when your method wants to create or modify data on the server.

const createTodoFunction = (newTodo) => axios.post('/todos', newTodo)

const mutation = useMutation(() => createTodoFunction(newTodo))

The mutation hook takes an Axios API post request and returns an object like the useQuery hook which includes the status , data, error , loading , and the mutate function. The mutate function is what triggers the request to the server.

Apart from this object returned by the useQuery and useMutation hooks, they have superpower callbacks / side effects, using the callbacks in your request changes everything.

useMutation({
  mutationFn: addTodo,
  onMutate: variables => {
    // A mutation is about to happen!

    // Optionally return a context containing data to use when for example rolling back
    return { id: 1 }
  },
  onError: (error, variables, context) => {
    // An error happened! we can show our error message her
    console.log(`rolling back optimistic update with id ${context.id}`)
  },
  onSuccess: (data, variables, context) => {
    // the mutation happened we can show our success notification here
  },
  onSettled: (data, error, variables, context) => {
    // Error or success... doesn't matter!
  },
})

React Query has come to change the game when it comes to data fetching. Your web apps behave like actual apps as the data is persisted after the initial fetching, and subsequent fetching would be done in the background. Additionally, you can trigger the data fetching by invalidating the data cache.

await queryClient.invalidateQueries( ['todos'])

For example, in the code snippet below the we would write a React Query Mutation function that demonstrates how optimistic queries work

const addTodo = (newTodo) => axios.post('/addTodo', newTodo)

useMutation((newTodo) => addTodo(newTodo), {

  onMutate: async variables => {
    // before this request is sent to the server, i want to do an optimistic update, so it looks instant
 // Cancel any outgoing refetches
    // (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries(['todos'])

    // Snapshot the previous value
    const previousTodos = queryClient.getQueryData(['todos'])

    // Optimistically update to the new value
    queryClient.setQueryData(['todos'], old => [...old, newTodo])

    // Return a context object with the snapshotted value,
    return { previousTodos }
  },
  // If the mutation fails,
  // use the context returned from onMutate to roll back
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
   onSuccess: () => {
    //we can use it to show our toat or notification
     alert("todo added")
  },
  // Always refetch after error or success:
  onSettled: () => {
    queryClient.invalidateQueries(['todos'])
  },
})

From the above snippet,

const addTodo = (newTodo) => axios.post('/addTodo', newTodo)

is your normal addTodo fetch function, that takes a newTodo and ends it to the server. we wrap this addTodo as the first parameter of our useMutation function

useMutation((newTodo) => addTodo(newTodo), {...})

the second part of the useMutation function are sideeffects exposed by React Query.

The onMutate function runs before the request is sent to the server

await queryClient.cancelQueries(['todos'])

This is used to cancel all instance of an outgoing refetching so that our optimistic update is not overwritten.

Next is get a snapshot of the current todos in the cache so that if our optimistic updates fail we can just return the snapshot and it will looks like nothing happened

 const previousTodos = queryClient.getQueryData(['todos'])

After the snapshot is taken, we construct a todo similar to what we expect in the backend and update our todo to the cache

 queryClient.setQueryData(['todos'], old => [...old, newTodo])

and then we return our snapshot

   return { previousTodos }

The onError function is run when there is an error in our network request. here is a good place to alert the user or set the cache to its previous state.

onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  }

This sets the cache back to its previous state

The onSuccess function is run when our network request is successful. here is a good place to alert the user of a successful request.

onSuccess: () => {
    //we can use it to show our toast or notification
     alert("todo added")
  }

The onSettled function is run after the fetch function has finished, irrespective of the result of the fetch function. it is advisable to always invalidate the query here so that it can refetch the item from the server and allow you have the latest server state.

onSettled: () => {
    queryClient.invalidateQueries(['todos'])
  }

Conclusion

React query is an amazing library! It gives you a ton of ways to manage your data, has great developer tools, and has very well-written documentation.

Everyone I’ve recommended React query has never looked back since then and that’s another example of how amazing the library is.

If you have any questions, you can leave them in the comments section below. And if you want to learn more about it, you can check out the React Query Official documentation.