Until now we have been importing a json file at src/heroes/Heroes.tsx, recall import heroes from './heroes.json'. Now that we have a backend server, we can get the data from the network.
From Kent C. Dodds' Epic React:
"HTTP requests are another common side-effect that we need to do in applications. This is no different from the side-effects we need to apply to a rendered DOM or when interacting with browser APIs like localStorage. In all these cases, we do that within a useEffect hook callback. This hook allows us to ensure that whenever certain changes take place, we apply the side-effects based on those changes."
As we load the HeroList, we need our application to make a GET request to the backend. Let's write a failing e2e test for it (Red 1). For now we can name the file anything. We load the HeroList but at the moment there are not GET requests to the server.
We will opt to use axios instead of the built in fetch api. yarn add axios. Use axios.get in a useEffect hook to make a GET request to our server (Green 1). useEffect takes a clean up function that can help us know if the component unmounted.
The test passes, but looking at the console we see that the component gets mounted, unmounted, and mounted again. Meanwhile, there are 2 calls to the api. The first does not have anything in the response body, the second gets the heroes. We will drop a side note here regarding theuseEffect dependency array and revisit the topic later.
useEffect(fn, [a, b, c]) -> run the effect when a, or b, or c change
useEffect(fn, [a]) -> run the effect when a changes
useEffect(fn, []) -> run the effect when... nothing changes, that's why it runs just once
useEffect(fn) -> run the effect at every render
Removing the hard-coded json data
We have been importing the heroes data from db.json file. Time to get that from the network. Disabling the import , we get type errors in the component because the variable does not exist anymore, the test also fails because there are no heroes in the list (Red 2)
We are getting some data with useEffect, but we have to store that data in a state variable in the component. We will utilize useState as in const [heroes, setHeroes] = useState([]) and set the heroes with what we get from the network.
That will work, but if we leave the test open we will see that we are making repeated GET requests to the server, and the component keeps mounting. We can use an empty useEffect dependency array to have the effect occur once when the component is rendered. For brevity, here is the changed code (Green 2):
useEffect(() => {console.log("mounting");console.log("heroes is :", heroes);getData().then((data) => {setHeroes(data); });return () =>console.log("unmounting");}, []); // empty array to have the effect occur only once
We can do a little bit more of a refactor adding support for axios error messages, and wrapping the expensive axios.get in a useCallBack. Why useCallback? In short, custom functions get defined on every render and can be costly especially if the network state is the same. useCallback lets us memoize such expensive operations, by preventing the redefinition or recalculation of values. The signature is useCallBack(updaterFn, [dependencies]) (Refactor 2).
Updating the component tests with network awareness
We used the e2e test to to drive the design of http requests in the Heroes component. Now that we are utilizing useEffect, the component will be making an axios request. We can see the network call take place and fail when running the component test Heroes.cy.tsx.
We need to be stubbing the network with some data, so that the component can render it. Add a cy.intercept using a fixture file for the network data to src/heroes/Heroes.cy.tsx and src/App.cy,tsx files, which both use the Heroes component. The intercept will ensure that all GET requests to http://localhost:4000/api/heroes will respond with the stubbed data from heroes.json file in Cypress fixtures.
// src/heroes/Heroes.cy.tsximport Heroes from"./Heroes";import { BrowserRouter } from"react-router-dom";import"../styles.scss";describe("Heroes", () => {beforeEach(() => {cy.intercept("GET","http://localhost:4000/api/heroes", { fixture:"heroes.json", }).as("getHeroes"); });it("should display the hero list on render, and go through hero add & refresh flow", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.wait("@getHeroes");cy.getByCy("list-header").should("be.visible");cy.getByCy("hero-list").should("be.visible");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes"); });constinvokeHeroDelete= () => {cy.getByCy("delete-button").first().click();cy.getByCy("modal-yes-no").should("be.visible"); };it("should go through the modal flow", () => {cy.window().its("console").then((console) =>cy.spy(console,"log").as("log"));cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("modal-yes-no").should("not.exist");cy.log("do not delete flow");invokeHeroDelete();cy.getByCy("button-no").click();cy.getByCy("modal-yes-no").should("not.exist");cy.log("delete flow");invokeHeroDelete();cy.getByCy("button-yes").click();cy.getByCy("modal-yes-no").should("not.exist");cy.get("@log").should("have.been.calledWith","handleDeleteFromModal"); });});
We also have to update the RTL unit test src/App.test.tsx which mirrors App.cy.tsx. Note that running the unit test it does not fail but the error will be a merge blocker. We only know what the problem is because either we have seen this before, or because we saw the network call happen on component mount in the component test runner using the real browser. Component testing with Cypress, using the real browser, can help diagnose issues in the app that may be harder to do using Jest / RTL.
In RTL, the equivalent of cy.intercept is msw. Install with yarn add -D msw. Modify the file as such:
Finally, in all the e2e tests, now we have to wait for the network data after visiting the url. To ensure that the page is stable and has loaded the network data, in create-hero.cy.tsx, edit-hero.cy.tsxnetwork.cy.tsx files, wrap all instances of cy.visit with an intercept and wait:
Using the ListHeader component's + button, we can add a hero from any screen. Our existing e2e test gets to HeroDetails by either navigating from hero list, or direct navigating to the url. Alas, we can also get to HeroDetails from edit hero, which is another render of HeroDetais with the hero data. This flow is interesting because the rendered Id field, and the data in the name and description fields need to clear upon clicking the + button. Let's write a test for it (Red 3).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() => {cy.intercept("GET","http://localhost:4000/api/heroes").as("getHeroes");cy.visit("/");cy.wait("@getHeroes");cy.location("pathname").should("eq","/heroes"); });it("should go through the cancel flow", () => {cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(0).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[0].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[0].id).should("be.visible");cy.findByDisplayValue(heroes[0].name).should("be.visible");cy.findByDisplayValue(heroes[0].description).should("be.visible");cy.getByCy("cancel-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); }); });it("should go through the cancel flow for another hero", () => {cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[1].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[1].id).should("be.visible");cy.findByDisplayValue(heroes[1].name).should("be.visible");cy.findByDisplayValue(heroes[1].description).should("be.visible");cy.getByCy("cancel-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); }); });it("should navigate to add from an existing hero", () => {cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(1).click();cy.getByCy("add-button").click();cy.getByCy("input-detail-id").should("not.exist");cy.findByDisplayValue(heroes[1].name).should("not.exist");cy.findByDisplayValue(heroes[1].description).should("not.exist"); }); });});
The test fails. The conditional rendering is working, but the state of the InputDetail component (child of HeroDetail) carries over.
Instead of taking the value and just displaying it, we need to make InputDetail aware of state. We can accomplish this by managing state where its most relevant, the component itself, and by utilizing a combination of useState and useEffect. We come up with a variable shownValue and a setter for it. As the component mounts, we utilize useEffect to set the value. We also specify a dependency array for the value (Green 3).
Similar to routing, when our concerns about the app are higher level as in state management and flows, e2e tests are effective at catching defects that we might not be able to test with component tests. The e2e test now works, and the component test serves as a regression assurance. The onChange now gets called twice vs thrice, and that is the only update.
// src/components/InputDetail.cy.tsximport InputDetail from"./InputDetail";import"../styles.scss";describe("InputDetail", () => {constplaceholder="Aslaug";constname="name";constvalue="some value";constnewValue="42";it("should allow the input field to be modified", () => {cy.mount( <InputDetailname={name}value={value}placeholder={placeholder}onChange={cy.stub().as("onChange")} /> );cy.contains("label", name);cy.findByPlaceholderText(placeholder).clear().type(newValue);cy.findByDisplayValue(newValue);cy.get("@onChange").its("callCount").should("eq",newValue.length); });it("should not allow the input field to be modified", () => {cy.mount( <InputDetailname={name}value={value}placeholder={placeholder}readOnly={true} /> );cy.contains("label", name);cy.findByPlaceholderText(placeholder).should("have.value", value).and("have.attr","readOnly"); });});
Refactoring
Environment variables
It is time to refactor all the references to http://localhost:4000/api with an environment variable. Create React App (CRA) comes with a dotenv package already installed. The only requirement is that variable names start with REACT_APP_. We can create an .env file right away with the api url.
The equivalent of .env is an env property in ./cypress.config.js . It can be specific to e2e, component or both depending where the property is placed.
Similarly, replace instances of the string http://localhost:4000/api in the component and e2e tests with a template literal ${Cypress.env('API_URL')}. The files that need changes are:
cypress/e2e/create-hero.cy.ts
cypress/e2e/edit-hero.cy.ts
cypress/e2e/network-hero.cy.ts
cypress/support/commands.ts
src/App.cy.tsx
src/components/InputDetail.cy.tsx
src/heroes/Heroes.cy.tsx
Custom hook useAxios
We can extract 20-30 lines of http logic into its own hook, and then use the hook in the Heroes component. Our hook accepts a route as the argument, returns an object of data, status & error. It also handles the concerns with useEffect cleanup. We will cover the details in the comments, and in the upcoming chapters promise to use a better solution.
// src/hooks/useAxios.tsimport { useCallback, useEffect, useState } from"react";import axios from"axios";constgetItem= (route:string) =>axios({ method:"GET", baseURL:`${process.env.REACT_APP_API_URL}/${route}`, }).then((res) =>res.data).catch((err) => {throwError(`There was a problem fetching data: ${err}`); });/** Takes a url, returns an object of data, status & error */exportdefaultfunctionuseAxios(url:string) {const [data,setData] =useState();const [error,setError] =useState(null);const [status,setStatus] =useState("idle");constgetItemCb=useCallback((route:string) => {returngetItem(route); }, []);// When fetching data within a call to useEffect,// combine a local variable and the cleanup function// in order to match a data request with its response:// If the component re-renders, the cleanup function for the previous render// will set the previous render’s doUpdate variable to false,// preventing the previous then method callback from performing updates with stale data.// eslint-disable-next-line @typescript-eslint/ban-ts-comment// @ts-ignoreuseEffect(() => {let doUpdate =true;setData(undefined);setError(null);setStatus("loading");getItemCb(url).then((data) => {if (doUpdate) {setData(data);setStatus("success"); } }).catch((error) => {if (doUpdate) {setError(error);setStatus("error"); } });return () => (doUpdate =false); }, [url]);return { data, status, error };}
At Heroes component, we do not need to utilize useState because now we get the data from useAxios. We just have to rename and initialize data variable into heroes .
We wrote a failing e2e test spying on an expected http GET call to /heroes route as the application loads (Red 1).
We added a useEffect that gets the data with an axios.get call targeting the /heroes route (Green 1).
We removed the json file import to get the data, utilized useState, while setting the hero array within the useEffect (Red 2, Green 2).
Refactors:
We used an empty array to have the http GET effect occur only once. We showcased useCallback to wrap expensive functions.
We updated the component tests and the unit test to be network aware and stub the network with cy.intercept for Cypress and msw for RTL.
We updated the e2e tests to wait for the network so that ui assertions can begin after the DOM settles.
We added a new e2e test to cover an alternate hero add flow; navigating to add hero from edit hero (Red 3).
To address the failure, we managed the state where it is most relevant; InputDetail component. We only used useState and useEffect. (Green 3)
Refactors:
We refactored the hard coded api route to an environment variable.
We used a hook useAxios to yield the data at the component in an abstracted way.
Takeaways
From Kent Dodds: "HTTP requests are another common side-effect that we need to do in applications. This is no different from the side-effects we need to apply to a rendered DOM or when interacting with browser APIs like localStorage. In all these cases, we do that within a useEffect hook callback. This hook allows us to ensure that whenever certain changes take place, we apply the side-effects based on those changes."
We can use the built in fetch api or axios to make http calls from the application to the backend.
useEffect dependency array:
useEffect(fn, [a, b, c]) -> run the effect when a, or b, or c change
useEffect(fn, [a]) -> run the effect when a changes
useEffect(fn, []) -> run the effect when... nothing changes, that's why it runs just once
useEffect(fn) -> run the effect at every render
Wrap expensive functions in useCallback to memoize repeated calls.
We can manage most http state with useState & useEffect, however the implementation can grow as the app scales.
Component testing with Cypress, using the real browser, can help diagnose issues in the app that may be harder to do using Jest/RTL.
If component tests are making network calls, we can stub the network with the cy.intercept api. The contrast to cy.intercept is msw for Jest/RTL .
Similar to routing, when our concerns about the app are higher level as in state management and flows, e2e tests are effective at catching edge cases that we might not be able to cover with component tests.