I recently got the opportunity (and the privilege!) of starting a greenfield project at my current company, involving an frontend application for an internal tool. The devs involved had the chance to choose the tech stack that we considered convenient, and we collaboratively wrote an RFC (Request for Comment) and presented it to the rest of the company to open up our choices to discussion.
One of the main points that came up -after settling for React, the lingua franca framework at our company- is how we would handle state management. Our main application uses Redux, but many other alternatives were brought up: MobX, using native hooks (a combination of useReducer + useContext), using Redux plus Redux Toolkit. I even got to know and proposed Recoil, a super exciting project -and definitely a library with one of the best presentation videos I've seen so far.
But our Staff Engineer Zac came up with a different idea. Enter React-Query.
React Query's novel approach to state management
"I haven't used it yet, but I love the different approach it takes to handle state within an application. It basically splits server side state out from client side state and automates a lot of stuff like re-fetching and caching", explained Zac.
The idea clicked with me instantly: most of the state that React apps keep in their store is just a reflection of data persisted remotely somewhere (a User, a list of Posts, Comments, or To-Dos, for e.g.). Only a minor part of it is client-side only, and it almost always corresponds to UI/UX information, like if whether a modal is open, a sidebar expanded, etc.
So the idea behind React Query is taking that majority of server-side state and handling it completely: fetching, re-fetching, storing, cacheing, updating and memoizing it in an all-in-one solution. This separations helps to reduce a lot of the boilerplate that inevitably arises with other combined client- and server-side state management tools (such as Redux).
The library also offers some advanced features like "optimistic updates", in which the library assumes an update to the data will be successful before actually receiving a response from the back-end, and allows to easily roll it back if it fails, making the app seem responsive as a breeze to the user.
Promising enough. We decided that we'd go with it during the Proof of Concept phase of the app and started writing code.
Writing the PoC with create-react-app
As we started working on the frontend way before our backend team had availability to build the services that would provide the data needed for the app, we decided to go forward with setting up our project with create-react-app with its TypeScript template and React Query using JSONPlaceholder as a fake API.
So, let's write some code!
First, we created a new app with create-react-app's CLI and installed react-query:
npx create-react-app react-query-demo --template=typescript
cd react-query-demo
yarn add react-query
The App.tsx
component that comes by default looks so:
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
Following React-Query's excellent documentation, we first modified that file by wrapping our app with the QueryClientProvider
that comes included in the library and created a new component UserList
where we will fetch our Users
from our fake API.
import React from 'react';
import { QueryClientProvider, QueryClient } from 'react-query';
import './App.css';
import { UserList } from "./UserList"
const queryClient = new QueryClient();
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<header className="App-header">
<h1>React Query Demo</h1>
</header>
<UserList />
</div>
</QueryClientProvider>
);
}
export default App;
Let's unpack our changes in that component. We first instantiated a new queryClient
instance with the QueryClient
constructor that React Query provides. We then passed that instance to the QueryClientProvider
with which we wrapped our whole app. This provides a context for our cached data and allows all components wrapped in it to use the querying and updating hooks that the library provides.
We also cleaned up our component a bit, changed the title and added our newly created UserList
component, where things start to get real interesting. Let's take a look at it:
import React from "react";
import { useQuery } from "react-query";
interface User {
id: number;
name: string;
username: string;
email: string;
}
const USERS_KEY = "users";
export const UserList = () => {
const {
isLoading,
data: users,
isError,
error
} = useQuery<User[], Error>(
USERS_KEY,
() => fetch('https://jsonplaceholder.typicode.com/users')
).then(res => {
if (!res.ok) {
throw new Error('Network response failed')
}
return res.json()
}));
if (isLoading) {
return <span>Loading...</span>;
}
if (isError) {
return <span>Error: {error?.message}</span>;
}
return (
<ul>
{users?.map(({ name, username, email }: User) => (
<div className="userRow">
<h3>{name}</h3>
<p>Username: {username}</p>
<p>{email}</p>
</div>
))}
</ul>
);
};
A lot more going on here, but this is where the juice of React Query really shows. Let's unpack all of it.
As we are using JSONPlaceholder's fake API to fetch a list of users, we first create the User
interface, a simplified version based on the schema provided by the site. In our case, we will fetch an array of Users and display it to the user.
Within our component, we make use of the main tool that React-Query provides: the useQuery
hook. The hook takes two arguments:
- a unique query key which is used internally by React Query for "refetching, caching and sharing queries across the application". The library will store the data under this key, in a similar way as data for different reducers are kept under a key name in Redux. In our case, we set it to the
USERS_KEY
constant, which is simply a string of value"users"
. - a function that returns a promise that resolves the data, or throws an error.
The second argument highlights one of library's great advantages: since React Query's fetching mechanisms are agnostically built on Promises, it can be used with literally any asynchronous data fetching client, such as Axios, the native fetch and even GraphQL! (we'll expand on how to do this in a subsequent post).
For now, we are using fetch to request a list of User
s from the https://jsonplaceholder.typicode.com/users
endpoint. Notice that, when using fetch, we must also manually check if the request is successful, and throw an error is if it not, as the second parameter expects the fetcher function to throw when an error occurs, and fetch does not automatically do this. This wouldn't be necessary if we were using Axios, for example.
Note for TypeScript users: React Query allows you to provide, via Generics, the result and error types of its hooks. This is especially useful when creating your own custom hooks, for example:
const useGetUsers = () => {
return useQuery<User[], Error>('users', fetchUsers)
}
The useQuery
hook returns an object, from where we have destructured three properties:
- isLoading: a boolean that indicates that the query has no data and is currently fetching.
-
data: the property that contains the data that the Promise resolved to if the request was successful. In our case, it is an array of
User
s, and we aliased it to the variable nameusers
just for clarity. - isError: a boolean that indicated that the query encountered an error.
- error: a property that contains the error thrown if the query is in an isError state.
We can use these properties to decide what the component should render, depending on the state of the query. We first check if it is in an isLoading
state, and render a message accordingly. We then check if it an error occured via the isError
boolean, and display the error under error.message
. Finally, we can safely assume that query is in isSuccess
state and render our list of users.
Updating our server-side state
So far so good, but what about when we need to create, update or delete our remotely stored data? React Query solves this issue with the concept of Mutations and the useMutation
hook.
Let's create another component CreateUser
that renders a button which POSTs a new user to the API when clicking on it, and add it to our App
.
[...]
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<header className="App-header">
<h1>React Query Demo</h1>
</header>
<UserList />
<CreateUser />
</div>
</QueryClientProvider>
);
}
export default App;
This time, we'll use Axios as our HTTP client to highlight React Query's versatility. Let's install it first:
yarn add axios
And let's write the code for our new component:
import React from "react";
import axios from "axios";
import { useMutation, useQueryClient } from "react-query";
import { User, USERS_KEY } from "./UserList";
const exampleUser = {
name: "John Doe",
email: "johndoe@gmail.com",
username: "johndoe1990"
} as User;
const postUser = (user: User) => axios
.post<User>('https://jsonplaceholder.typicode.com/users', user);
export const CreateUser = () => {
const queryClient = useQueryClient();
const { isLoading, mutate } = useMutation(postUser, {
onSuccess: () => {
queryClient.invalidateQueries(USERS_KEY);
}
});
const onButtonClick = () => mutate(exampleUser);
if (isLoading) {
return <p>Creating User...</p>;
}
return <button onClick={onButtonClick}>Click to post a new user</button>;
};
Let's go over what's going on here.
Firstly, we create a hard-coded exampleUser
to POST into the fake API when the user clicks on the button. We also create our required mutation function, postUser
, which returns a Promise of an Axios Response of a POST call to our /users
endpoint, passing in as data the argument of our mutation function.
Inside our component, we'll first initialize an instance of queryClient
with the useQueryClient
hook, also provided by React Query. This is the same instance created in App.tsx
and provided by our QueryClientProvider
. We'll make use of it in a second.
And now we make use of the useMutation
hook, also provided by React Query, which takes two arguments:
- a required mutation function that performs an asynchronous task and returns a Promise. In our case, we pass in the already defined
postUser
function. - an object with multiple properties:
- an optional mutation key, in a similar way as we defined a query key, to be used internally. We don't need to set one for this example.
- an optional onSuccess callback, that fires when the mutation is successful and is passed the mutation result.
- an optional onError callback that will fires if the mutation fails, and will get the error passed.
- an optional onMutate callback, that fires before the mutation function is fired and is passed the same variables the mutation function would receive. This allows us to do optimistic updates: that is, we can early update a resource (and our UI) in the hope that the mutation succeeds and give our application a "synchronous feel". The value returned from this function will be passed to the onError and the onSettled callbacks, so that we can rollback our optimistic update in case the mutations fails.
- more config properties can be found in the docs.
In our example, we are only setting up an onSuccess
callback whose job is to invalidate our "users"
query, by calling the invalidateQueries
utility provided by our queryClient
and passing our USERS_KEY
as argument to it. By invalidating this query key in our cache after the mutation is successful, we indicate React Query that the data under that key is outdated and it should refetch it. Thus, the library will automatically re-query our /users
endpoint, and will bring back our updated Users
list.
The useMutation
hook returns an object from where we destructure two properties:
- mutate: a function that can be called passing variables to it as parameters and will trigger the mutation defined in the mutation function defined in the hook.
- isLoading: a boolean that indicates that the mutation is still pending.
Our CreateUser
component will use mutate when clicking on a button, so we create an onButtonClick
function that fires mutate
passing it our hardcoded exampleUser
as an argument. We then use our isLoading
flag to show an appropriate message to the user when the mutation is pending, or display the button with a call-to-action otherwise.
And that's it! Try playing around with it in the app. One note though, if you check the Network tab on the DevTools, you'll see that, because we are using a fake API, the POST call to add a user will indeed succeed with status code 201
. However, when React Query refetches the data, (the subsequent GET call that gets fired after we invalidate our query key) the new user won't be in the returned data there as JSONPlaceholder will simply ignore any new data added to it. However, on a real API you would see the User
you just posted.
Wrapping it up
We've seen how React Query can handle the fetching of the data, cacheing and updating (via refetching) and provides us with a user-friendly hook to handle the returned data and rendering. Through the use of its query keys, and its simple API, the library can replace a fully fledged state management solution, taking out of your hands the responsibility of writing hundred of lines of boilerplate code, and adding great functionality that you'd otherwise have to write from scratch.
Check out the finished demo app and clone the repo to play around with the code. And don't forget to give the official documentation a read.
Thanks for reading!
Top comments (7)
Great write up dude. 👌🏾
Only 1/4 a way though and just have to say this is such a well written article.
Its 2023 and this article is still the best resource I could find on react query. Thanks a ton!
Great article for morning 👌
Great article Juan!
Cleared up a bunch on react query