On Next.js, React Query, and learning to love async/await
React Query is a clever package of hooks and tools for the React/Next family of frameworks written by Tanner Linsley. I’m familiarizing myself with it because I’m expected to contribute to several web apps soon which all rely heavily on asynchronous data. Managing state with flying Promise
s and fetch
es everywhere makes my vertigo act up… but Next.js and React Query soothe the nausea a bit — with a learning curve.
Let’s say I have a to-do list app. The front end in Next has a list of tasks and a controlled form; when a user adds a task, the front end should update the DOM and update a back end with fetch( "...", { method: "POST" } )
. The form looks a bit like this:
import { useState } from 'react';import homeStyles from '../styles/Home.module.css';const AddToDoForm = () => { const [ toDoFormState, setToDoFormState ] = useState( "" ); return <form
className={ homeStyles.form }
onSubmit={ ...a fetch/post/state change of some kind... }
>
<input
value={ toDoFormState }
onChange={ changeEvent => setToDoFormState( changeEvent.target.value ) }
/>
<input type="submit" value="Do it!" />
</form>;};export default AddToDoForm;
Without React Query, I might decide to create a global state in the App
component, and then pass around getters and setters as props
to keep that global state updated as I fetch
. I might even set up a Redux store
if I’m feeling frosty. But… that approach stinks with asynchronous data. Why waste grey hairs and lines of code making sure database and state are always dancing at the same tempo?
With React Query, I can fetch
all the live-long day without ever worrying about global state at all. I just give my app a URL, a method, and some data, point to where/when an update should occur… and React Query does the rest. To make it happen, after installing with npm i react-query
or yarn add react-query
, I backtrack to the app’s index page and wrap the entire component in a QueryClientProvider
:
import { QueryClient, QueryClientProvider } from 'react-query';import AddToDoForm from '../components/AddToDoForm';...const queryClient = new QueryClient();const ToDoCards = () => { ...component with a div for each task... }export default function Home() { return <QueryClientProvider client={ queryClient }>
...
<AddToDoForm />
<ToDoCards />
</QueryClientProvider>;}
And just like that, now that the Hero of Time has a queryClient
, he gains the magical useQuery
hook, giving him the ability to manage global state as he fetch
es:
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';...const ToDoCards = () => {
const { isLoading, data } = useQuery( "todos", async () => {
const response = await fetch( `...` );
return response.json();
} );
return <div className={ homeStyles.grid }>
{ isLoading ? "Loading..." : data.map( todo => <ToDoCard
key={ todo.id }
todo={ todo }
allTodos={ data }
/> ) }
</div>;
}export default function Home() { return ...; }
useQuery
takes two arguments: a key, used to refer to this resource in the future, and a callback that actually fetch
es and return
s some json()
. useQuery
also returns other variables to de-structure, like status
and error
messages; in this case, I’m using the isLoading
boolean for a nice loading message where the cards live while the user waits for a response.
The React Query hook for making POST
and PATCH
requests is useMutation()
. Let’s put it to work back in the AddToDoForm
component:
...import { useMutation, useQueryClient } from 'react-query';const handleSubmission = async ( { toDoFormState } ) => {
const response = await fetch( `http://localhost:${ 3001 }/todos`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify( { ...new todo from ToDoFormState... } )
} );
return response.json();
};const AddToDoForm = () => { const queryClient = new QueryClient(); const [ toDoFormState, setToDoFormState ] = useState( "" ); const { mutate } = useMutation( handleSubmission, {
onSuccess: async newToDo => {
setToDoFormState( "" );
await queryClient.cancelQueries( "todos" );
const previousValue = queryClient.getQueryData( "todos" );
queryClient.setQueryData( "todos", previousQueryData => [ ...previousQueryData, newToDo ] );
return previousValue;
},
} ); return <form
className={ homeStyles.form }
onSubmit={ async submissionEvent => {
submissionEvent.preventDefault();
try { mutate( { toDoFormState } ); }
catch ( error ) { console.log( error ); }
} }
>
<input
value={ toDoFormState }
onChange={ changeEvent => setToDoFormState( changeEvent.target.value ) }
/>
<input type="submit" value="Do it!" />
</form>;};export default AddToDoForm;
To note:
- Just as before, I’m returning
json()
fetch
edasync
hronously — but notice in myhandleSubmission
function I’m de-structuring thetoDoFormState
from my controlled form in the arguments. - The
mutate
function is de-structured fromuseMutation
, which takes two arguments: thehandleSubmission
callback I just defined which makes thefetch
, and an object with one or more of three keys:onError
,onSuccess
, and/oronSettled
(either error or success), each of which points to a function with some instructions for ourqueryClient
for responding to each situation. For brevity’s sake, here I’m just defining anonSuccess
method, where I 1) clear the form state, 2)setQueryData
for our“todos”
key to the...previousQueryData
with the new database entry tacked onto the end, and 3) return the previous query data. - Finally, I can call
mutate( {} )
— making sure, of course, to wrap it in atry {} catch () {}
just in case afetch
goes awry.
React Query isn’t falling off a log, but in an app with constant fetch
ing, I’d argue it’s well worth the trouble. No state props like todosToDisplay
or setTodosToDisplay
… no need to worry about dispatch
or store
or context
… and lots of other caching and scrolling hooks that improve app performance.
The code above lives here, and a great set of docs/examples are here.