ch12-Heroes-part1-lifting-state
Heroes
component utilizes the other hero components and is the most complex so far. Looking at the Angular version of the app, we can come up with a bullet list of the DOM elements.ListHeader
child componentdiv
- A route that switches between
HeroList
andHeroDetail
ModalYesNo
component (for delete operation)

Heroes-initial
Create a branch
feat/Heroes
. Create 2 files under src/heroes/
folder; Heroes.cy.tsx
, Heroes.tsx
. As usual, start minimal with a component rendering; copy the below to the files and execute the test after opening the runner with yarn cy:open-ct
.// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import "../styles.scss";
describe("Heroes", () => {
it("should", () => {
cy.mount(<Heroes />);
});
});
// src/components/Heroes.tsx
export default function Heroes() {
return <div>hello</div>;
}
We start with a test that checks for the
ListHeader
component (Red 1).// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import "../styles.scss";
describe("Heroes", () => {
it("should", () => {
cy.mount(<Heroes />);
cy.getByCy("list-header");
});
});
To make the test work, we need to include the child component in the render, and add the necessary attributes;
title
, handleAdd
, handleRefresh
. Any value will do for now (Red 1).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
export default function Heroes() {
return (
<div data-cy="heroes">
<ListHeader title="Heroes" handleAdd={""} handleRefresh={""} />
</div>
);
}
We still get a test error, and it is a familiar one about routing. It is because the child component
ListHeader
is using react-router
. Recall from ListHeader
and HeaderBarBrand
components that any time we are using react-router
, we have to wrap the mounted component in BrowserRouter
in the component test (Green 1).// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
});
});

Heroes-Green1
Click the icons and we get type errors. Recall that while testing
ListHeader
component in isolation, we used cy.stub
for handleAdd
and handleRefresh
. Now the component is being used as a child, and React cannot use cy.stub
. The parent / the consumer of the child has to implement this handler function.Let's improve the tests with the two clicks that trigger the failures. For now it suffices to spy on console logs as we did in the tests
HeroDetail.cy.tsx
and HeroList.cy.tsx
(Red 2).// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should handle hero add and refresh", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
cy.getByCy("add-button").click();
cy.get("@log").should("have.been.calledWith", "handleAdd");
cy.getByCy("refresh-button").click();
cy.get("@log").should("have.been.calledWith", "handleRefresh");
});
});
To make the test pass, we need to add functions that
console.log
with the respective strings (Green 2).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
export default function Heroes() {
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={() => console.log("handleAdd")}
handleRefresh={() => console.log("handleRefresh")}
/>
</div>
);
}
We can refactor those into their own functions and that suffices for the
ListHeader
for now (Refactor 2).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
</div>
);
}

Heroes-Refactor2
When we render
Heroes
, at first ListHeader
and the HeroList
display. If we Edit a hero, the HeroDetail
displays. If we Delete a hero, ModalYesNo
is shown. We will first focus on the HeroList
, then the modal. We will tackle HeroDetail
after setting up routing in a later chapter.We start simple with a test that checks for the
HeroList
render (Red 3).// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should handle hero add and refresh", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
cy.getByCy("add-button").click();
cy.get("@log").should("have.been.calledWith", "handleAdd");
cy.getByCy("refresh-button").click();
cy.get("@log").should("have.been.calledWith", "handleRefresh");
});
it("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");
});
});
We add the child
HeroList
to our component. It requires a heroes prop. One idea is to look at the component tests for children, see how they are used, and work off of that documentation when writing the tests for the parent component. We do not need to repeat any tests at the parent, but we can use the help to give an idea about how the child should be mounted. Take a look at HeroList.cy.tsx
. We are importing a Cypress fixture and passing it as a prop. We can repeat a similar process, and delay the decisions about data and state to a later time until we have to make them (Green 3).If a component is importing a file from outside the source folder, the component will work in isolation but the greater app will not compile. Make a copy of
heroes.json
from cypress/fixtures/
in src/heroes
and update Heroes
component to use this file instead. We will handle this gracefully later when working with network data.// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
</div>
);
}

Heroes-Green3
Once again we can look at the component tests for children, see how they are used, and work off of that documentation when writing the tests for the parent component.
ModalYesNo.cy.tsx
has props for a message
string, onYes
and onNo
events. It also supports an internal state which allows the modal to be toggled.Let's write a failing test. For now, we do not have a toggle for the modal, so we should only run the new modal test (Red 4).
// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should handle hero add and refresh", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
cy.getByCy("add-button").click();
cy.get("@log").should("have.been.calledWith", "handleAdd");
cy.getByCy("refresh-button").click();
cy.get("@log").should("have.been.calledWith", "handleRefresh");
});
it("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");
});
it.only("should display the modal", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("modal-yes-no");
});
});
To render the child component, we just have to add the props
message
, onNo
, onYes
. For now it is all right for them to be empty strings (Green 4).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
<ModalYesNo
message="Would you like to delete the hero?"
onNo={""}
onYes={""}
/>
</div>
);
}

Heroes-Green4
Having run that test, we really want a way to close that modal and see our
Heroes
component. Let's write a failing test for this need (Red 5).From here onwards, for the sake of brevity, when a test is executed with.only
we will only be showing the code for the relevant portion.
// src/components/Heroes.cy.tsx
it.only("should display the modal", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("modal-yes-no");
cy.getByCy("button-no").click();
});
We get an error in the Cypress runner
func.apply is not a function
. Become familiar with this error, it means our event handler isn't doing anything. To resolve it, for now use a function that console.log
s (Green 5).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
<ModalYesNo
message="Would you like to delete the hero?"
onNo={() => console.log("handleCloseModal")}
onYes={""}
/>
</div>
);
}
We can refactor that into its own function (Refactor 5).
// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => () => console.log("handleCloseModal");
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={""}
/>
</div>
);
}
We covered the
useState
hook in the HeroDetail
function. There were two key takeaways in that chapter. First was that we can simplify our UI state management into two categories:- 1.UI state: modal is open, item is highlighted, etc.
- 2.Server data.
In the case of the modal, it is category 1; UI state.
The second key takeaway was that we prefer to manage state where it is most relevant. In this case whether the modal is open or closed is most relevant in the
Heroes
component, and useState
hook is the simplest way to satisfy that.We have 3 requirements about the modal. The flow goes as such:
- We would like the modal to be closed when
Heroes
is rendered - When we want to delete a hero, we want to display the modal.
- We would like the modal to go away when clicking No in the modal
Let's write a failing test for the first step of the flow; when rendering the component the modal should be closed. We slightly modify the
it
block with comments (Red 6).// src/components/Heroes.cy.tsx
it.only("should display the modal", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("modal-yes-no").should("not.exist");
// delete the hero
// cy.getByCy('modal-yes-no').should('be.visible')
// select no
// cy.getByCy('button-no').click()
// cy.getByCy('modal-yes-no').should('not.exist')
});
To make the test pass, we can just use a
false
chain before the ModalYesNo
component (Green 6).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
export default function Heroes() {
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => () => {
console.log("handleCloseModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
{false && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={""}
/>
)}
</div>
);
}
Let's continue writing the test. We need to click the button, and the modal should pop up (Red 7).
// src/components/Heroes.cy.tsx
it.only("should display the modal", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("modal-yes-no").should("not.exist");
cy.getByCy("delete-button").first().click();
cy.getByCy("modal-yes-no").should("be.visible");
// select no
// cy.getByCy('button-no').click()
// cy.getByCy('modal-yes-no').should('not.exist')
});
To make this state toggle work, we need to use
useState
. We do not like that hard-coded false
and it can be used as the initial state of the hook. At this point in time, the test is still expected to fail.// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
export default function Heroes() {
const [showModal, setShowModal] = useState(false);
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => () => {
console.log("handleCloseModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} />
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={() => console.log("handleOnYes")}
/>
)}
</div>
);
}
showModal
looks great in there, but we need to be able to setShowModal
to true when clicking the Delete
button. Take a look at the console, handleDeleteHero
is called and this function lives in HeroList
component. This is a hint that the two child components are sharing state.
Heroes-Red8
We will reference Kent C. Dodds' Application State Management with React and give you the gist of the article:
- If components are sharing state, lift the state up to their closest common ancestor.
- If the common ancestor is too deep and lifting state results in prop-drilling, use React's context api.
- Beyond that, use state management libraries.
In our case
Heroes
component hosts two children HeroList
and ModalYesNo
; lifting state up to the parent is the easiest choice.ModalYesNo
component is already relaying its onYes
and onNo
onClick
handlers above. On the other hand, HeroList
implements its own handleDeleteHero
onClick
handler. Instead we need to pass a prop handleDeleteHero
to HeroList
which is driven by setShowModal
in Heroes
component. Therefore we have to make a modification to HeroList
component. We remove the self-implemented handleDeleteHero
function, and instead pass it as a prop. We leave the prop type generic for the time being.// src/components/HeroList.tsx
import CardContent from "../components/CardContent";
import ButtonFooter from "../components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
type HeroListProps = {
heroes: Hero[];
handleDeleteHero: () => void; // TODO: consider better type
};
export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul data-cy="hero-list" className="list">
{heroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} key={hero.id}>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
</li>
))}
</ul>
);
}
We update the matching test to accept the new
handleDeleteHero
prop. It suffices to use cy.stub
to ensure that it is called on click.// src/components/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
import heroes from "./heroes.json";
describe("HeroList", () => {
it("should render the item layout", () => {
cy.mount(
<HeroList
heroes={heroes}
handleDeleteHero={cy.stub().as("handleDeleteHero")}
/>
);
cy.getByCyLike("hero-list-item").should("have.length", heroes.length);
cy.getByCy("card-content");
cy.contains(heroes[0].name);
cy.contains(heroes[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
context("handleDelete, handleEdit", () => {
beforeEach(() => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<HeroList
heroes={heroes}
handleDeleteHero={cy.stub().as("handleDeleteHero")}
/>
);
});
it("should handle delete", () => {
cy.getByCy("delete-button").first().click();
cy.get("@handleDeleteHero").should("have.been.called");
});
it("should handle edit", () => {
cy.getByCy("edit-button").first().click();
cy.get("@log").should("have.been.calledWith", "handleSelectHero");
});
});
});
Back in the parent component
Heroes
, now we can pass a prop handleDeleteHero
. The value of it needs to be a function that returns setShowModal(<a boolean arg>)
. Why do all the click handlers have to be functions? Per the React docs when using JSX you pass a function as the event handler, rather than a string. After the changes, the test passes. (Green 7).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
export default function Heroes() {
const [showModal, setShowModal] = useState(false);
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => () => {
console.log("handleCloseModal");
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList
heroes={heroes}
handleDeleteHero={() => setShowModal(true)}
/>
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={() => console.log("handleOnYes")}
/>
)}
</div>
);
}
We can refactor
() => setShowModal(true)
into its own function. We can also remove the .only
in the component test (Refactor 7).// src/components/Heroes.tsx
import ListHeader from "../components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import HeroList from "./HeroList";
import heroes from "./heroes.json";
import { useState } from "react";
export default function Heroes() {
const [showModal, setShowModal] = useState<boolean>(false);
const addNewHero = () => console.log("handleAdd");
const handleRefresh = () => console.log("handleRefresh");
const handleCloseModal = () => {
console.log("handleCloseModal");
};
const handleDeleteHero = () => {
setShowModal(true);
};
return (
<div data-cy="heroes">
<ListHeader
title="Heroes"
handleAdd={addNewHero}
handleRefresh={handleRefresh}
/>
<div>
<div>
<HeroList heroes={heroes} handleDeleteHero={handleDeleteHero} />
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the hero?"
onNo={handleCloseModal}
onYes={() => console.log("handleOnYes")}
/>
)}
</div>
);
}
Time for the next failing test in the modal flow; when
button-no
is clicked the modal should go away (Red 8).// src/components/Heroes.cy.tsx
import Heroes from "./Heroes";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("Heroes", () => {
it("should handle hero add and refresh", () => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("list-header");
cy.getByCy("add-button").click();
cy.get("@log").should("have.been.calledWith", "handleAdd");
cy.getByCy("refresh-button").click();
cy.get("@log").should("have.been.calledWith", "handleRefresh");
});
it("should display hero list on render", () => {
cy.mount(
<BrowserRouter>
<Heroes />
</BrowserRouter>
);
cy.getByCy("hero-list");