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:
- Queries
- 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 bystatus === 'loading'
.isError
: This returns true when the query encountered an error. This is also represented bystatus === 'error'
.isSuccess
: This returns true when the query was successful and data is available. This is also represented bystatus === 'success'
isIdle
: This returns true when the query is currently disabled. This is also represented bystatus === 'idle'
Asides from those, more fields returned based on the state of the query include:
error
: If the query is in anisError
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 bystatus === 'loading'
.isError
: This returns a value of true when the mutation encountered an error. This is also represented bystatus === 'error'
.isSuccess
This returns true when the mutation was successful and mutation data is available. This is also represented bystatus === 'success'
.isIdle
: This returns true when the mutation is currently idle or in a fresh/reset state. This is also represented bystatus === 'idle'
.
Asides from those, more fields returned based on the state of the query include:
error
- If the mutation is in anisError
state, the details of error is available via theerror
property.data
- If the mutation is in asuccess
state, the data is available via thedata
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