React Query managing remote state - vonschappler/Ultimate-React GitHub Wiki
React Query is a powerful library for managing remote state, being sometimes describes by many users as the library that React is missing itself.
React Query has so many features which allows is to write less code while also making the user experiencie a lot better. Here is a list of some of those features:
- Fetched data is stored in a cache - this means that the data is fetched and sotred for reusability, allwoing this data to be loaded instantly on all components which requires that data.
- Provides automatic loading and error states - this means that we can focus on other important parts of the code.
- Provided automatic re-fetching in some situations to keep state synched, like for example after a certain timeout or after we leave the browser window and come back to it.
- Provides data pre-fetching data, which means that we can fetch data that it's going to be used in a later moment, without being necessary the rendering at the instant it was fetched, like for example when working on pagination.
- Provides easy remote state mutation/updates, with the tools provided with it
- Provides offline support, meaning that components which requires server-side data can still be displayed if the connection between the application and the server is lost, because the data was already fetched and stored in the cache.
React Query is needed because remote state is different from UI state. Remote state can easly get out of sync if multiple instances of the application are running concurrently (like for example many uses accessing using the application at the same time), and so it makes this tool really important for projects like this.
To use React Query, we first need to install it inside the application, using the commands below:
# installing a specific version
npm i @tanstack/react-query@<version_number>
npm i @tanstack/react-query-devtools@<version_number>
# installing the latest version
npm i @tanstack/react-query@latest
npm i @tanstack/react-query-devtools@latest
Note that here we also installed the dev tools because they come handy during the develoment process
After the library is installed, some setup is required on our application. To do so, we edit our source code files to become something like what's shown in snippet of code below:
// file App.jsx
// other react imports
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
{/* other application components*/}
</QueryClientProvider>
);
}
export default App;
The idea behind React Query is similar to what we do with the Context API and Redux.
For detailed information about the library, always keep the documentation at hand!
After providing our query client to the application as described above, it's time to use the useQuery custom hook for fetch data into our components.
So for this example, we are going to use the CabinsTable component as an example on how to fetch some data using React Query:
// file apiCabins.js
import supabase from 'path/to/supabase/defintions';
export async function getCabins() {
const { data, error } = await supabase.from('cabins').select('*');
if (error) {
console.log(error);
throw new Error('Cabins could not be loaded');
}
return data;
}
// file CabinsTable.jsx
import { useQuery } from '@tanstack/react-query';
import { getCabins } from 'path/to/apiCabins';
import CabinRow from 'path/to/CabinRow';
import Spinner from 'path/to/Spinner';
// styled component definitions
function CabinTable() {
const {
isLoading,
data: cabins,
error,
} = useQuery({
queryKey: ['cabins'],
queryFn: getCabins,
});
if (isLoading) return <Spinner />;
return {
/* component elements */
};
}
export default CabinTable;
Notice that the useQuery hook in here needs at least two options:
- queryKey - the "name" with which the data will be saved in the cache, always passed as an array of strings
- queryFn - the async function used to fetch the data, in this cased imported from file apiCabins.js
This hook will always return the result of the query, with a lot of properties provided by React Query, but the most important (for this specfic case) are the fetching state (isLoading), the data itself (data, which in the code we renamed to cabins, as a way to help in idetinfying what this data retried is all about) and the error, in case any error happens, which stores the error object thrown, as defined in the apiCabins.js, in case the query goes wrong.
React Query comes with another useful hook, called useMutation, which, as the name says, is used to make mutations on the data stored on the server.
This means that with this hook we can make all other CRUD operations which are not done with the hooks useQuery and useQueryClient. The way to implement it is displayed on the code below, using the component CabinRow as an example:
// file apiCabins.js
// other api functions definition
export async function deleteCabin(id) {
const { data, error } = await supabase.from('cabins').delete().eq('id', id);
if (error) {
console.log(error);
throw new Error('Cabin could not be deleted');
}
return data;
}
// file CabinRow.jsx
// other react imports
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { formatCurrency } from 'path/to/helpers';
import { deleteCabin } from 'path/to/apiCabins';
// styled components definitions
function CabinRow({ cabin }) {
const {
id: cabinId,
name,
maxCapacity,
regularPrice,
discount,
image,
} = cabin;
const queryClient = useQueryClient();
const { isLoading: isDeleting, mutate } = useMutation({
mutationFn: deleteCabin,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['cabins'],
});
},
onError: (err) => alert(err.message),
});
return (
<TableRow role='row'>
{/* other table colums */}
<button onClick={() => mutate(cabinId)} disabled={isDeleting}>
Delete
</button>
</TableRow>
);
}
export default CabinRow;
Note that the useQueryClient hook defines this component as a consumer of the client defined on App.jsx, allowing us to have access to the query named cabins in here.
With this access, we can then set up our mutation with the useMutation hook, which recevies (in this case) three options:
- mutationFn - the async function to be called for the mutation
- onSuccess - a property that defines a callback function to be executed when the mutation is completed with success
- onError - a property that defines a callback function to be excecuted when the mutation is rejected
As mentioned, React Query can re-fetch data in certain situations and one of those situatios is when the cache becomes invalid. The way to make the cache invalid is exactly what's done in the callback function passed to the onSuccess option, which will then automatically re-fetch the data after the mutation is completed.
As part of the Tech Stack for this example project, we decided that we'd use a 3td-party library called react-hot-toast for displaying notifications.
In order to use that, we need to install that library on our application using the command:
npm i react-hot-toast
When the library is installed, we have to implement the toasts in the main application (App.jsx), just as shown below:
//file App.jsx
// other react imports
import { Toaster } from 'react-hot-toast';
// other component definitions
function App() {
return (
<QueryClientProvider client={queryClient}>
<Toaster
position='top-center'
gutter={12}
containerStyle={{ margin: '8px' }}
toastOptions={optionsObject}
/>
</QueryClientProvider>
);
}
export default App;
Then, to make use of the toaster in the application, all that is required is to then make use of the function toast, where it's necessary in the application. For example, we are using react-hot-toast to replace the error alert inside CabinRow component:
// file Cabin.Row.jsx
// react imports and styled components definitions
function CabinRow({ cabin }) {
const {
id: cabinId,
name,
maxCapacity,
regularPrice,
discount,
image,
} = cabin;
const queryClient = useQueryClient();
const { isLoading: isDeleting, mutate } = useMutation({
mutationFn: deleteCabin,
onSuccess: () => {
toast.success('Cabin successfully deleted!');
queryClient.invalidateQueries({
queryKey: ['cabins'],
});
},
onError: (err) => toast.error(err.message),
});
return {
/* returned react component*/
};
}
export default CabinRow;
For reference about this library, check the documentation, where all the information about this library can be found.
React Hook Form is a 3rd-party library used to simplify form management in React single page applications. To use this library, we install it on our project using:
# using a specific version
npm i react-hook-form@<version_number>
# using the latest version
npm i react-hook-form@latest
The documentation for this library can be found on the library website.
Note that this library will provide access to the custom hook useForm. This hook itself provides then access to some functions, which we can destructure for easy access on our code. The most common functions used are register (used to register form inputs into the hook by their ids) and handleSubmit.
Then we set up it as shown in the snippets of code below, for adding a new entry to the database (insert mutation):
// file apiCabins.js
// other api functions definitions
export async function createCabin(newCabin) {
const { data, error } = await supabase
.from('cabins')
.insert([newCabin])
.select();
if (error) {
console.log(error);
throw new Error('Cabin could not be created');
}
return data;
}
// file CreateCabinForm.jsx
// other react imports
import { useForm } from 'react-hook-form';
import { createCabin } from 'path/to/apiCabins';
// styled components definitions
function CreateCabinForm() {
const queryClient = useQueryClient();
const { register, handleSubmit, reset } = useForm();
const { mutate, isLoading: isCreating } = useMutation({
mutationFn: createCabin,
onSuccess: () => {
toast.success('New cabin successfully created');
queryClient.invalidateQueries(['cabins']);
reset();
},
onError: (err) => {
toast.error(err.message);
},
});
function onSubmit(data) {
mutate(data);
}
return (
<Form onSubmit={handleSubmit(onSubmit)}>
<FormRow>
<Label htmlFor='name'>Cabin name</Label>
<Input type='text' id='name' {...register('name')} />
</FormRow>
{/* more form elements which are registered to the form on the same away as above */}
<FormRow>
{/* type is an HTML attribute! */}
<Button variation='secondary' type='reset'>
Cancel
</Button>
<Button>Add cabin</Button>
</FormRow>
</Form>
);
}
export default CreateCabinForm;
In order to validate a form using React Hook Form we need to get access to another function returned by the useForm hook: the formState.
This function will return an object with the validation form errors based on the what was defined for each form input element.
It's also possible to create custom validator functions, and even make comparisons between different form inputs, as well as set up multiple validators for the same field. The generic snippet below shows a quick example of how to perform some validation in two different fields:
import { useForm } from 'react-hook-form';
function CustomForm() {
const { register, handleSubmit, getValues, formState } = useForm();
const { errors } = formState;
function sbmtError(errors) {
console.error(errors);
}
// here it's possible also do define something to be done in case the form submission return an error
return (
<form onSubmit={handleSubmit(sbmtSuccess, sbmtError)}>
<input id='number1' type='number' {...register('number1'), {
require: `This is a required field`,
}} />
{errors?.number1?.message && <span>{errors?.number1?.message}</span>}
<input
id='number2'
type='number'
{...register('number2', {
require: `The divider number can\'t be empty`,
min: {
value: 1,
message: `It's not possible to divide by 0`
},
validate: (value) => {return Number(value) <= Number(getValues().number1) || `I can't calculate this...`}
})}
/>
{errors?.number2?.message && <span>{errors?.number2?.message}</span>}
</form>
);
}
export default CustomForm;
Note that the validators are passed as a second argument to the function register used to register an form input field to the hook useForm.
In order to be able to implement the update mutation, we should change our code base (apiCabins.js, CreateCabinForm.jsx and CabinRow.jsx), follwing the snippets of code below:
// file apiCabins.js
import supabase, { supabaseUrl } from './supabase';
// other queries / mutation definitions
export async function createEditCabin(newCabin, id) {
const hasImagePath = newCabin.image?.startsWith?.(supabaseUrl);
const imageName = `${Math.random()}-${newCabin.image.name}`.replaceAll(
'/',
''
);
const imagePath = hasImagePath
? newCabin.image
: `${supabaseUrl}/storage/v1/object/public/cabin-images/${imageName}`;
let query = supabase.from('cabins');
// creates a new cabin, since no id is passed to the query
if (!id) query = query.insert([{ ...newCabin, image: imagePath }]);
// edits the current cabin with the id passed
if (id) query = query.update({ ...newCabin, image: imagePath }).eq('id', id);
const { data, error } = await query.select().single();
if (error) {
console.log(error);
throw new Error('Cabin could not be created');
}
const { error: storageError } = await supabase.storage
.from('cabin-images')
.upload(imageName, newCabin.image);
if (storageError) {
await supabase.from('cabins').delete().eq('id', data.id).select();
console.error(storageError);
throw new Error(
'Cabin image could not be uploaded and the cabin was not created / updated'
);
}
return data;
}
// file CabinRow.jsx
// react / libraries import
// styled-component definitions
function CabinRow({ cabin }) {
const [showForm, setShowForm] = useState(false);
// other definitions
return (
<>
<TableRow role='row'>
<Img src={image} />
<Cabin>{name}</Cabin>
<div>Fits up to {maxCapacity} guests</div>
<Price>{formatCurrency(regularPrice)}</Price>
<Discount>{formatCurrency(discount)}</Discount>
<div>
<button onClick={() => mutate(cabinId)} disabled={isDeleting}>
Delete
</button>
<button onClick={() => setShowForm((show) => !show)}>Edit</button>
</div>
</TableRow>
{/* added to the code, so the edit form can be displayed */}
{showForm && <CreateCabinForm cabinToEdit={cabin} />}
</>
);
}
export default CabinRow;
// file CreateCabinForm.jsx
// react imports
function CreateCabinForm({ cabinToEdit = {} }) {
// definition of the cabin we wish to edit
const { id: editId, ...editValues } = cabinToEdit;
// sets the form in edit mode, if an id is passed
const isEditSession = Boolean(editId);
// edited this, so the form can pre-fill values already existent when it's in "edit mode"
const { register, handleSubmit, reset, getValues, formState } = useForm({
defaultValues: isEditSession ? editValues : {},
});
// changed add mutation to prevent conflicting of constant names
const { mutate: createCabin, isLoading: isCreating } = useMutation({
mutationFn: createEditCabin,
onSuccess: () => {
toast.success('New cabin successfully created');
queryClient.invalidateQueries(['cabins']);
reset();
},
onError: (err) => {
toast.error(err.message);
},
});
// mutation added for editing an entry
const { mutate: editCabin, isLoading: isEditing } = useMutation({
mutationFn: ({ newCabinData, id }) => createEditCabin(newCabinData, id),
onSuccess: () => {
toast.success('Cabin successfully updated');
queryClient.invalidateQueries(['cabins']);
reset();
},
onError: (err) => {
toast.error(err.message);
},
});
const isWorking = isCreating || isEditing;
function onSubmit(data) {
const image = typeof data.image === 'string' ? data.image : data.image[0];
if (isEditSession)
editCabin({ newCabinData: { ...data, image }, id: editId });
else createCabin({ ...data, image });
}
// other definitions
return (
<Form onSubmit={handleSubmit(onSubmit, onError)}>
{/* other FormRow elements - note that in all of them, the disabled property was changed from disabled={isCreating} to disabled={isWorking} defined above */}
<FormRow label='Cabin image: ' error={errors?.image?.message}>
{/* changed to make so that the image is not required on edit, since we may not need to change the old image*/}
<FileInput
id='image'
accept='image/*'
{...register('image', {
required: isEditSession ? false : 'This field is required...',
})}
/>
</FormRow>
<FormRow>
<Button variation='secondary' type='reset' disabled={isWorking}>
Cancel
</Button>
{/* changes the button text based on which case we a using (create or edit)*/}
<Button disabled={isWorking}>
{isEditSession ? `Edit cabin` : `Create new cabin`}
</Button>
</FormRow>
</Form>
);
}
export default CreateCabinForm;
Note: these files were edited so we could reuse the code for both creating and editing a cabin entry on the database. Another posibility would also to create separed components/functions separatedly, but here we opted to make use of reusability.