ch17-react-query

Caching and react-query

Having learned well from Kent C. Dodds, In the previous chapters we stated that we can drastically simplify our UI state management if we split out the server cache into something separate. State can be lumped into two buckets:
  1. 1.
    UI state: Modal is open, item is highlighted, etc. (we used useState hook for this)
  2. 2.
    Server cache: User data, tweets, contacts, etc. (react-query is useful here)
Why react-query? To prevent duplicated data-fetching, we want to move all the data-fetching code into a central store and access that single source from the components that need it. With React Query, we don’t need to do any of the work involved in creating such a store. It lets us keep the data-fetching code in the components that need the data, but behind the scenes it manages a data cache, passing already-fetched data to components when they ask for them.
React-query's useQuery hook is for fetching data by key and caching it, while updating cache. Think of it similar to a GET request. The key arg is a unique identifier for the query / data in cache; string, array or object. The 2nd arg an async function that returns the data.
const { data, status, error } = useQuery(key, () => fetch(url))
useMutation is the write mirror of useQuery. Think of it similar to our PUT and POST requests. useMutation yields data, status, error just like useQuery. The first arg is a function that that executes a non-idempotent request. The second arg is an object with onMutate property.
const { dataToMutate, status, error } = useMutation((*url*) => fetch(*url*) {...})
  • useQuery fetches state: UI state <- server/url , and caches it.
  • useMutation is just the opposite: UI state -> server , and still caches it.
In this chapter we will be creating our api, creating hooks for CRUD operations on heroes, and we will be using them in the components.

API

We will be replicating the getItem function in the useAxios hook, and making it compatible with the rest of the CRUD requests. Create a file src/hooks/api.ts and paste in the following code. We have a type protected client function which wraps Axios, with which we can make any CRUD request. We wrap them again in functions with less arguments, which are easier to use.
// src/hooks/api.ts
import axios from "axios";
import { Hero } from "models/Hero";
export type CrudType = "GET" | "POST" | "PUT" | "DELETE";
export type CrudOptions = { item?: Hero | object; config?: object };
export const client = (route: string, method: CrudType, item?: Hero | object) =>
axios({
method,
baseURL: `${process.env.REACT_APP_API_URL}/${route}`,
data: method === "POST" || method === "PUT" ? item : undefined,
})
.then((res) => res.data)
.catch((err) => {
throw Error(`There was a problem fetching data: ${err}`);
});
export const createItem = (route: string, item: Hero | object) =>
client(route, "POST", item);
export const editItem = (route: string, item: Hero | object) =>
client(route, "PUT", item);
export const deleteItem = (route: string) => client(route, "DELETE");
export const getItem = (route: string) => client(route, "GET");

useGetHeroes

We can create a simple hook and replace our complex useAxios, while showcasing the performance gains of cache management by react-query. yarn add react-query, and create a file src/hooks/useGetHeroes.ts. react-query's useQuery is similar to our custom useAxios: takes a url, returns an object of data, status & error.
const { data, status, error } = useQuery(key, () => fetch(url))
Compare to useAxios, which also returns a status and error that we did not use:
const {data: heroes = []} = useAxios('heroes')
Whenever any component subsequently calls useQuery with the key, react-query will return the previously fetched data from its cache and then fetch the latest data in the background (very similar to PWAs and service workers). Our query key here is the string heroes and the callback function is getItem from our api, calling the /heroes route. useQuery returns data, status, and error, we reshape those nicely for an easier use in our component.
// src/hooks/useGetHeroes.ts
import { useQuery } from "react-query";
import { getItem } from "./api";
/**
* Helper for GET to `/heroes` route
* @returns {object} {heroes, status, getError}
*/
export const useGetHeroes = () => {
const query = useQuery("heroes", () => getItem("heroes"));
return {
heroes: query.data,
status: query.status,
getError: query.error,
};
};
Before replacing useAxios in Heroes component, we have to wrap our app JSX in a provider component called QueryClientProvider , instantiate a queryClient and use it as the client prop of QueryClientProvider. This is how we make the cache available for components to access and share.
// src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import About from "About";
import HeaderBar from "components/HeaderBar";
import NavBar from "components/NavBar";
import NotFound from "components/NotFound";
import Heroes from "heroes/Heroes";
import "./styles.scss";
const queryClient = new QueryClient();
function App() {
return (
<BrowserRouter>
<HeaderBar />
<div className="section columns">
<NavBar />
<main className="column">
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/" element={<Navigate replace to="/heroes" />} />
<Route path="/heroes/*" element={<Heroes />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</QueryClientProvider>
</main>
</div>
</BrowserRouter>
);
}
export default App;
useGetHeroes is a drop-in replacement for useAxios, and it does not even need an argument. We will use status and getError in the next chapter.
import { useNavigate, Routes, Route } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import { useState } from "react";
import HeroDetail from "./HeroDetail";
import { useGetHeroes } from "hooks/useGetHeroes";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const { heroes, status, getError } = useGetHeroes();
const navigate = useNavigate();
const addNewHero = () => navigate("/heroes/add-hero");
const handleRefresh = () => navigate("/heroes");
const handleCloseModal = () => {
setShowModal(false);
};
const handleDeleteHero = () => {
setShowModal(true);
};
const handleDeleteFromModal = () => {
setShowModal(false);
console.log("handleDeleteFromModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<Routes>
<Route
path=""
element={
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
}
/>
<Route path="/add-hero" element={<HeroDetail />} />
<Route path="/edit-hero/:id" element={<HeroDetail />} />
<Route
path="*"
element={
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
}
/>
</Routes>
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
Serve the app with yarn dev, and toggle useAxios vs useGetHeroes. Switch between the tabs and observe the performance difference thanks to caching.

usePostHero

Until now we have not had a feature to add a hero. Our backend supports it, but our front end does not. Let's start with a failing test which goes through the add hero flow. Our new test simply visits the main route, clicks the add button, verifies the new page, fills in randomized hero name and description, saves the changes (Red 1)
// cypress/e2e/create-hero.cy.ts
import { faker } from "@faker-js/faker";
describe("Create hero", () => {
it("should go through the refresh flow", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/heroes/add-hero");
cy.wait("@getHeroes");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it.only("should go through the add hero flow (ui-e2e)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newHero.name);
cy.getByCy("input-detail-description").type(newHero.description);
cy.getByCy("save-button").click();
// things work until here
cy.location("pathname").should("eq", "/heroes");
});
});
Let's remember useMutation before proceeding further.
  • useParams (from react-router) and useQuery (from react-query) fetch state:
    UI state <- server/url , and caches it
  • useMutation is just the opposite:
    UI state -> server , and still caches it
useMutation yields data, status, error just like useQuery.
const { mutate, status, error } = useMutation((item) => createItem(route, item)), {onSuccess: ...}
The first arg is a function that that executes a non-idempotent request. The second arg is an object with onSuccess property.
Here is our incomplete hook we derive off of that knowledge. We expect that it will create something in the backend with our api call, it will log some new data, and it will navigate to /heroes
// src/hooks/usePostHero.ts
import { Hero } from "models/Hero";
import { useMutation } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "./api";
/**
* Helper for simple POST to `/heroes` route
* @returns {object} {mutate, status, error}
*/
export function usePostHero() {
const navigate = useNavigate();
return useMutation((item: Hero) => createItem("heroes", item), {
onSuccess: (newData) => {
console.log(newData);
return navigate(`/heroes`);
},
});
}
In HeroDetail component we have a createHero function that console.logs. Our replacement createHero function can be the mutate value yielded from the hook. We can cast the return values of the hook like so:
const {mutate: createHero, status: postStatus, error: postError} = usePostHero()
We can remove const createHero = () => console.log('createHero'), instead use the createHero yielded from the hook. We need to pass to it an item argument. The type for it comes from our usePostHero hook's useMutation callback:
useMutation((item: Hero) => createItem('heroes', item)
We can pass it the hero we get from the already existing useState:
const [hero, setHero] = useState({id, name, description})
There is one final update for the ternary operator in handleSave. Currently it is working off of hero.name, and hero is driven by state, so hero.name is what we type in. It will always be true. We need to base it on something that does not exist in the case of a new hero, and that can be the name and description we get from the url:
const {name, description} = useHeroParams()
When there is no hero, there are no url search parameters. Therefore we can replace the ternary operator and the {hero.name} jsx for card-header-title with just name.
Here is the updated HeroDetail component (Green 1):
// src/heroes/HeroDetail.tsx
import { useState, ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FaUndo, FaRegSave } from "react-icons/fa";
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import { useHeroParams } from "hooks/useHeroParams";
import { usePostHero } from "hooks/usePostHero";
import { Hero } from "models/Hero";
export default function HeroDetail() {
const navigate = useNavigate();
const { id } = useParams();
const { name, description } = useHeroParams();
const [hero, setHero] = useState({ id, name, description });
const { mutate: createHero, status, error: postError } = usePostHero();
const handleCancel = () => navigate("/heroes");
const updateHero = () => console.log("updateHero");
const handleSave = () => {
console.log("handleSave");
return name ? updateHero() : createHero(hero as Hero);
};
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log("handleNameChange");
setHero({ ...hero, name: e.target.value });
};
const handleDescriptionChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log("handleDescriptionChange");
setHero({ ...hero, description: e.target.value });
};
return (
<div data-cy="hero-detail" className="card edit-detail">
<header className="card-header">
<p className="card-header-title">{name}</p>
&nbsp;
</header>
<div className="card-content">
<div className="content">
{id && (
<InputDetail name={"id"} value={id} readOnly={true}></InputDetail>
)}
<InputDetail
name={"name"}
value={name ? name : ""}
placeholder="e.g. Colleen"
onChange={handleNameChange}
></InputDetail>
<InputDetail
name={"description"}
value={description ? description : ""}
placeholder="e.g. dance fight!"
onChange={handleDescriptionChange}
></InputDetail>
</div>
</div>
<footer className="card-footer">
<ButtonFooter
label="Cancel"
IconClass={FaUndo}
onClick={handleCancel}
/>
<ButtonFooter label="Save" IconClass={FaRegSave} onClick={handleSave} />
</footer>
</div>
);
}
ReactQuery-Green1
Now we can verify if the hero we just created appears in the list after the save. For brevity we are showing only the running test (Red 2).
// cypress/e2e/create-hero.cy.ts
it.only("should go through the add hero flow (ui-e2e)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newHero.name);
cy.getByCy("input-detail-description").type(newHero.description);
cy.getByCy("save-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list")
.should("be.visible")
.should("contain", newHero.name)
.and("contain", newHero.description);
});
We see the new entity created at the backend (db.json got updated), and if we navigate to another tab and back we also see the newly created entity. Alas, it is not in the HeroList immediately after saving. This points to a shortcoming in cache management. When we mutate the backend, we also have to update the new cache. For this we use queryClient's setQueryData method. setQueryData takes a key as the first arg, the 2nd arg is a callback that takes the old query cache and returns the new one. With that enhancement, our test is passing (Green 2).
// src/hooks/usePostHero.ts
import { Hero } from "models/Hero";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "./api";
/**
* Helper for simple POST to `/heroes` route
* @returns {object} {mutate, status, error}
*/
export function usePostHero() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation((item: Hero) => createItem("heroes", item), {
onSuccess: (newData: Hero) => {
queryClient.setQueryData(["heroes"], (oldData: Hero[] | undefined) => [
...(oldData || []),
newData,
]);
return navigate(`/heroes`);
},
});
}
Here is our e2e test at the moment:
// cypress/e2e/create-hero.cy.ts
import { faker } from "@faker-js/faker";
describe("Create hero", () => {
it("should go through the refresh flow", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/heroes/add-hero");
cy.wait("@getHeroes");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the add hero flow (ui-e2e)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newHero.name);
cy.getByCy("input-detail-description").type(newHero.description);
cy.getByCy("save-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list")
.should("be.visible")
.should("contain", newHero.name)
.and("contain", newHero.description);
});
});

ui-e2e vs ui-integration tests

Pay attention to the first two tests. They end the add flow with cancel or refresh. Neither of them sends a write request to the backend, they just read the data from it. The fact that we are reading the data from the backend, and the fact that we are writing to the backend is covered in the 3rd test, which should stay e2e. But the first two tests can entirely stub the network, and thereby become ui-integration tests. What are ui-integration tests? From List of Test Methodologies post:
These look like UI e2e tests but they fully stub the network, and they are run without hitting a real server. They are faster, and less brittle than traditional UI e2e tests since the network is not a concern. They are great for shift-left approach and to isolate the ui functionality prior to testing on deployments where the backend matters.
If you have been through Kent C. Dodd's Epic React you might have seen his version of integration tests, using React Testing Library. The distinction here is that we are using the real UI, and testing the integration of components at a higher level, only stubbing the network data. For some, seeing the real browser, in fact the real app itself is easier and more confident, and that is the path we will follow in this course.
Always evaluate if you need the backend to gain confidence in your app's functionality. You should only use true e2e tests when you need this confidence, and you should not have to repeat the same costly tests everywhere. Instead utilize ui-integration tests. If your backend is tested by its own e2e tests, your true e2e needs at the front end are even less; be careful not to duplicate the backend effort. In our repo, cypress/e2e/backend/crud.cy.ts is a good example of a backend e2e test. Coincidentally we will be using some of its commands in our e2e to reset or setup db state.
Let's refactor our test file to use ui-integration tests for cancel and refresh flows. Instead of the network data, we will use the heroes.json file under cypress/fixtures. We will also refactor some of the common navigation between the tests (Refactor 2).
// cypress/e2e/create-hero.cy.ts
import { faker } from "@faker-js/faker";
describe("Create hero", () => {
const navToAddHero = () => {
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
};
it("should go through the refresh flow (ui-integration)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
cy.visit("/");
cy.wait("@stubbedGetHeroes");
navToAddHero();
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation (ui-integration)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
cy.visit("/heroes/add-hero");
cy.wait("@stubbedGetHeroes");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the add hero flow (ui-e2e)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
navToAddHero();
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newHero.name);
cy.getByCy("input-detail-description").type(newHero.description);
cy.getByCy("save-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list")
.should("be.visible")
.should("contain", newHero.name)
.and("contain", newHero.description);
});
});
Since our delay for json-server is 1 second, the test is now 2 seconds faster, less flakey, and our confidence has not reduced because we already cover the real GET request in the third test.
There are two more refactors remaining. We will be needing to visit the baseUrl in stubbed and natural ways, so those two can become Cypress commands. We are also bloating the db every time the third test is executed. We can use the delete command from the backend-e2e suite, and we can also reset the database just like the backend suite does.
Add 3 commands getEntityByName, visitStubbedHeroes, visitHeroes to the commands file.
// cypress/support/commands.ts
import { Hero } from "../../src/models/Hero";
import data from "../fixtures/db.json";
Cypress.Commands.add("getByCy", (selector, ...args) =>
cy.get(`[data-cy="${selector}"]`, ...args)
);
Cypress.Commands.add("getByCyLike", (selector, ...args) =>
cy.get(`[data-cy*=${selector}]`, ...args)
);
Cypress.Commands.add("getByClassLike", (selector, ...args) =>
cy.get(`[class*=${selector}]`, ...args)
);
Cypress.Commands.add(
"crud",
(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | object; allowedToFail?: boolean } = {}
) =>
cy.request<Hero[] & Hero>({
method: method,
url: `${Cypress.env("API_URL")}/${route}`,
body: method === "POST" || method === "PUT" ? body : undefined,
retryOnStatusCodeFailure: !allowedToFail,
failOnStatusCode: !allowedToFail,
})
);
Cypress.Commands.add("resetData", () =>
cy.crud("POST", "reset", { body: data })
);
const { _ } = Cypress;
Cypress.Commands.add("getEntityByName", (name: Hero["name"]) =>
cy
.crud("GET", "heroes")
.its("body")
.then((body: Hero[]) => _.filter(body, (hero: Hero) => hero.name === name))
.its(0)
);
Cypress.Commands.add("visitStubbedHeroes", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
cy.visit("/");
cy.wait("@stubbedGetHeroes");
return cy.location("pathname").should("eq", "/heroes");
});
Cypress.Commands.add("visitHeroes", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`).as("getHeroes");
cy.visit("/");
cy.wait("@getHeroes");
return cy.location("pathname").should("eq", "/heroes");
});
Add the type definitions to cypress.d.ts
// cypress.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MountOptions, MountReturn } from "cypress/react";
import type { Hero } from "./cypress/support/commands";
export {};
declare global {
namespace Cypress {
interface Chainable {
/** Yields elements with a data-cy attribute that matches a specified selector.
* ```
* cy.getByCy('search-toggle') // where the selector is [data-cy="search-toggle"]
* ```
*/
getByCy(qaSelector: string, args?: any): Chainable<JQuery<HTMLElement>>;
/** Yields elements with data-cy attribute that partially matches a specified selector.
* ```
* cy.getByCyLike('chat-button') // where the selector is [data-cy="chat-button-start-a-new-claim"]
* ```
*/
getByCyLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Yields the element that partially matches the css class
* ```
* cy.getByClassLike('StyledIconBase') // where the class is class="StyledIconBase-ea9ulj-0 lbJwfL"
* ```
*/
getByClassLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Mounts a React node
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
mount(
component: React.ReactNode,
options?: MountOptions
): Cypress.Chainable<MountReturn>;
/** Visits baseUrl, uses real network, verifies path */
visitHeroes(): Cypress.Chainable<string>;
/** Visits baseUrl, uses stubbed network, verifies path */
visitStubbedHeroes(): Cypress.Chainable<