Before starting this section, make sure to go through the prerequisite where we update the application to be more generic between Heroes and Villains.
In this chapter we will mirror heroes into villains, and apply the Context api to villains. Context api lets us pass a value deep into the component tree, without explicitly threading it through every component. This will give us a nice contrast between passing the state as prop to child components, versus using the context to share the state down the component tree.
Heroes.tsx passes heroes as a prop to HeroList.tsx.
HeroDetail gets hero{id, name, description} state from the url with useParams & useSearchParams .
This is the extent of state management in our app. We do not really need context, but we will explore how things can be different with the context api.
Context API for villains
At the moment we have a full mirror of heroes to villains, functioning and being tested exactly the same way. We will however modify the villains group and take advantage of Context api while doing so.
Villains.tsx passes villains as a prop to VillainList.tsx. We will instead use the Context api so that villains is available in all components under Villains.txt.
Here are the general steps with Context api:
Create the context and export it. Usually this is in a separate file, acting as an arbiter.
// src/villains/VillainsContext.tsx (the common node)import { Villain } from"models/Villain";import { createContext } from"react";constVillainsContext=createContext<Villain[]>([]);exportdefault VillainsContext;
Identify the state to be passed down to child components. Import the context there.
In our example, from Villains.tsx, we are passing villains to VillainDetail.tsx. We get villains from the hook useGetEntity . We are currently using a prop to pass villains to VillainDetail.tsx, and we want to instead use the context api. So we import the context, and wrap the routes with the context provider, which has a value prop with villains assigned to it (Steps 2, 3).
VillainsList.tsx needs to be passed down the state via context versus a prop. So we import the context, and importuseContext from React. We invoke useContext with the shared context as its argument, and assign to a variable villains (Steps 4, 5). Now, instead of the prop, we are getting the state from the VillainsContext.
// src/villains/VillainList.tsximport { useNavigate } from"react-router-dom";import CardContent from"components/CardContent";import ButtonFooter from"components/ButtonFooter";import { FaEdit, FaRegSave } from"react-icons/fa";import { ChangeEvent, MouseEvent, useTransition, useEffect, useState, useDeferredValue,} from"react";import { useContext } from"react";import { Villain } from"models/Villain";import VillainsContext from"./VillainsContext";typeVillainListProps= {handleDeleteVillain: ( villain:Villain ) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionVillainList({ handleDeleteVillain }:VillainListProps) {constvillains=useContext(VillainsContext);constdeferredVillains=useDeferredValue(villains);constisStale= deferredVillains !== villains;const [filteredVillains,setFilteredVillains] =useState(deferredVillains);constnavigate=useNavigate();const [isPending,startTransition] =useTransition();// needed to refresh the list after deleting a villainuseEffect(() =>setFilteredVillains(deferredVillains), [deferredVillains]);consthandleSelectVillain= (villainId:string) => () => {constvillain=deferredVillains.find((h:Villain) =>h.id === villainId);navigate(`/villains/edit-villain/${villain?.id}?name=${villain?.name}&description=${villain?.description}` ); };typeVillainProperty=|Villain["name"]|Villain["description"]|Villain["id"];/** returns a boolean whether the villain properties exist in the search field */constsearchExists= (searchProperty:VillainProperty, searchField:string) =>String(searchProperty).toLowerCase().indexOf(searchField.toLowerCase()) !==-1;/** given the data and the search field, returns the data in which the search field exists */constsearchProperties= (searchField:string, data:Villain[]) => [...data].filter((item:Villain) =>Object.values(item).find((property:VillainProperty) =>searchExists(property, searchField) ) );/** filters the villains data to see if the any of the properties exist in the list */consthandleSearch= (data:Villain[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnstartTransition(() =>setFilteredVillains(searchProperties(searchField, data)) ); };return ( <divstyle={{ opacity: isPending ?0.5:1, color: isStale ?"dimgray":"black", }} > {deferredVillains.length>0&& ( <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(deferredVillains)} /> </div> )} <uldata-cy="villain-list"className="list"> {filteredVillains.map((villain, index) => ( <lidata-cy={`villain-list-item-${index}`} key={villain.id}> <divclassName="card"> <CardContentname={villain.name}description={villain.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteVillain(villain)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectVillain(villain.id)} /> </footer> </div> </li> ))} </ul> </div> );}
Update the component test to also use the context provider when mounting.
// src/villains/VillainList.cy.tsximport VillainList from"./VillainList";import"../styles.scss";import villains from"../../cypress/fixtures/villains.json";import VillainsContext from"./VillainsContext";describe("VillainList", () => {it("no villains should not display a list nor search bar", () => {cy.wrappedMount( <VillainListhandleDeleteVillain={cy.stub().as("handleDeleteVillain")} /> );cy.getByCy("villain-list").should("exist");cy.getByCyLike("villain-list-item").should("not.exist");cy.getByCy("search").should("not.exist"); });context("with villains in the list", () => {beforeEach(() => {cy.wrappedMount( <VillainsContext.Providervalue={villains}> <VillainListhandleDeleteVillain={cy.stub().as("handleDeleteVillain")} /> </VillainsContext.Provider> ); });it("should render the villain layout", () => {cy.getByCyLike("villain-list-item").should("have.length",villains.length );cy.getByCy("card-content");cy.contains(villains[0].name);cy.contains(villains[0].description);cy.get("footer").first().within(() => {cy.getByCy("delete-button");cy.getByCy("edit-button"); }); });it("should search and filter villain by name and description", () => {cy.getByCy("search").type(villains[0].name);cy.getByCyLike("villain-list-item").should("have.length",1).contains(villains[0].name);cy.getByCy("search").clear().type(villains[2].description);cy.getByCyLike("villain-list-item").should("have.length",1).contains(villains[2].description); });it("should handle delete", () => {cy.getByCy("delete-button").first().click();cy.get("@handleDeleteVillain").should("have.been.called"); });it("should handle edit", () => {cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/villains/edit-villain/"+ villains[0].id ); }); });});
Update the RTL test to also use context provider when rendering.
// src/villains/VillainList.test.tsximport VillainList from"./VillainList";import { wrappedRender, screen, waitFor } from"test-utils";import userEvent from"@testing-library/user-event";import { villains } from"../../db.json";import VillainsContext from"./VillainsContext";describe("VillainList", () => {consthandleDeleteVillain=jest.fn();it("no villains should not display a list nor search bar",async () => {wrappedRender(<VillainListhandleDeleteVillain={handleDeleteVillain} />);expect(awaitscreen.findByTestId("villain-list")).toBeInTheDocument();expect(screen.queryByTestId("villain-list-item-1")).not.toBeInTheDocument();expect(screen.queryByTestId("search-bar")).not.toBeInTheDocument(); });describe("with villains in the list", () => {beforeEach(() => {wrappedRender( <VillainsContext.Providervalue={villains}> <VillainListhandleDeleteVillain={handleDeleteVillain} /> </VillainsContext.Provider> ); });constcardContents=async () =>screen.findAllByTestId("card-content");constdeleteButtons=async () =>screen.findAllByTestId("delete-button");consteditButtons=async () =>screen.findAllByTestId("edit-button");it("should render the villain layout",async () => {expect(awaitscreen.findByTestId(`villain-list-item-${villains.length-1}`) ).toBeInTheDocument();expect(awaitscreen.findByText(villains[0].name)).toBeInTheDocument();expect(awaitscreen.findByText(villains[0].description) ).toBeInTheDocument();expect(awaitcardContents()).toHaveLength(villains.length);expect(awaitdeleteButtons()).toHaveLength(villains.length);expect(awaiteditButtons()).toHaveLength(villains.length); });it("should search and filter villain by name and description",async () => {constsearch=awaitscreen.findByTestId("search");userEvent.type(search, villains[0].name);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1));awaitscreen.findByText(villains[0].name);userEvent.clear(search);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(villains.length) );userEvent.type(search, villains[2].description);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1)); });it("should handle delete",async () => {userEvent.click((awaitdeleteButtons())[0]);expect(handleDeleteVillain).toHaveBeenCalled(); });it("should handle edit",async () => {userEvent.click((awaiteditButtons())[0]);awaitwaitFor(() =>expect(window.location.pathname).toEqual("/villains/edit-villain/"+ villains[0].id ) ); }); });});
Using a custom hook for sharing context
The context, created at VillainsContext is acting as the arbiter. Villains.tsx uses the context to share villains state to its child VillainList. VillainList acquires the state from VillainsContext. Instead of VillainsContext we can use a hook that will reduce the imports.
Remove src/villains/VillainsContext.ts and instead create a hook src/hooks/useVillainsContext.ts. The first half of the hook is the same as VillainsContext. We add a setter, so that the components that get passed down the state also gain the ability to set it. Additionally we manage the state and effects related to the hook’s functionality within the hook and return only the value(s) that components need.
// src/hooks/useVillainsContext.tsimport { createContext, useContext, SetStateAction, Dispatch } from"react";import { Villain } from"models/Villain";// Context api lets us pass a value deep into the component tree// without explicitly threading it through every component (2nd tier state management)constVillainsContext=createContext<Villain[]>([]);// to be used as VillainsContext.Provider,// takes a prop as `value`, which is the context/data/state to shareexportdefault VillainsContext;constVillainsSetContext= createContext<Dispatch< SetStateAction<Villain[] |null>>|null>(null);// Manage state and effects related to a hook’s functionality// within the hook and return only the value(s) that components needexportfunctionuseVillainsContext() {constvillains=useContext(VillainsContext);constsetVillains=useContext(VillainsSetContext);return [villains, setVillains] asconst;}
At Villains.tsx the only change is the import location of VillainsContext.
Similarly, at VillainsList component test, only the import changes. We are using the hook useVillainsContext. The same applies to VillainList.test.tsx.
// src/villains/VillainList.cy.tsximport VillainList from"./VillainList";import"../styles.scss";import villains from"../../cypress/fixtures/villains.json";import VillainsContext from"hooks/useVillainsContext";describe("VillainList", () => {it("no villains should not display a list nor search bar", () => {cy.wrappedMount( <VillainListhandleDeleteVillain={cy.stub().as("handleDeleteVillain")} /> );cy.getByCy("villain-list").should("exist");cy.getByCyLike("villain-list-item").should("not.exist");cy.getByCy("search").should("not.exist"); });context("with villains in the list", () => {beforeEach(() => {cy.wrappedMount( <VillainsContext.Providervalue={villains}> <VillainListhandleDeleteVillain={cy.stub().as("handleDeleteVillain")} /> </VillainsContext.Provider> ); });it("should render the villain layout", () => {cy.getByCyLike("villain-list-item").should("have.length",villains.length );cy.getByCy("card-content");cy.contains(villains[0].name);cy.contains(villains[0].description);cy.get("footer").first().within(() => {cy.getByCy("delete-button");cy.getByCy("edit-button"); }); });it("should search and filter villain by name and description", () => {cy.getByCy("search").type(villains[0].name);cy.getByCyLike("villain-list-item").should("have.length",1).contains(villains[0].name);cy.getByCy("search").clear().type(villains[2].description);cy.getByCyLike("villain-list-item").should("have.length",1).contains(villains[2].description); });it("should handle delete", () => {cy.getByCy("delete-button").first().click();cy.get("@handleDeleteVillain").should("have.been.called"); });it("should handle edit", () => {cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/villains/edit-villain/"+ villains[0].id ); }); });});
// src/villains/VillainList.test.tsximport VillainList from"./VillainList";import { wrappedRender, screen, waitFor } from"test-utils";import userEvent from"@testing-library/user-event";import { villains } from"../../db.json";import VillainsContext from"hooks/useVillainsContext";describe("VillainList", () => {consthandleDeleteVillain=jest.fn();it("no villains should not display a list nor search bar",async () => {wrappedRender(<VillainListhandleDeleteVillain={handleDeleteVillain} />);expect(awaitscreen.findByTestId("villain-list")).toBeInTheDocument();expect(screen.queryByTestId("villain-list-item-1")).not.toBeInTheDocument();expect(screen.queryByTestId("search-bar")).not.toBeInTheDocument(); });describe("with villains in the list", () => {beforeEach(() => {wrappedRender( <VillainsContext.Providervalue={villains}> <VillainListhandleDeleteVillain={handleDeleteVillain} /> </VillainsContext.Provider> ); });constcardContents=async () =>screen.findAllByTestId("card-content");constdeleteButtons=async () =>screen.findAllByTestId("delete-button");consteditButtons=async () =>screen.findAllByTestId("edit-button");it("should render the villain layout",async () => {expect(awaitscreen.findByTestId(`villain-list-item-${villains.length-1}`) ).toBeInTheDocument();expect(awaitscreen.findByText(villains[0].name)).toBeInTheDocument();expect(awaitscreen.findByText(villains[0].description) ).toBeInTheDocument();expect(awaitcardContents()).toHaveLength(villains.length);expect(awaitdeleteButtons()).toHaveLength(villains.length);expect(awaiteditButtons()).toHaveLength(villains.length); });it("should search and filter villain by name and description",async () => {constsearch=awaitscreen.findByTestId("search");userEvent.type(search, villains[0].name);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1));awaitscreen.findByText(villains[0].name);userEvent.clear(search);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(villains.length) );userEvent.type(search, villains[2].description);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1)); });it("should handle delete",async () => {userEvent.click((awaitdeleteButtons())[0]);expect(handleDeleteVillain).toHaveBeenCalled(); });it("should handle edit",async () => {userEvent.click((awaiteditButtons())[0]);awaitwaitFor(() =>expect(window.location.pathname).toEqual("/villains/edit-villain/"+ villains[0].id ) ); }); });});
At VillainList.tsx, as in Villains.tsx the import changes. Additionally, we do not need to import useContext. We now de-structure villains; const [villains] = useVillainsContext(). If we needed to we could also get the setter out of the hook to set the context.
// src/villains/VillainList.tsximport { useNavigate } from"react-router-dom";import CardContent from"components/CardContent";import ButtonFooter from"components/ButtonFooter";import { FaEdit, FaRegSave } from"react-icons/fa";import { ChangeEvent, MouseEvent, useTransition, useEffect, useState, useDeferredValue,} from"react";import { useVillainsContext } from"hooks/useVillainsContext";import { Villain } from"models/Villain";typeVillainListProps= {handleDeleteVillain: ( villain:Villain ) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionVillainList({ handleDeleteVillain }:VillainListProps) {const [villains] =useVillainsContext();constdeferredVillains=useDeferredValue(villains);constisStale= deferredVillains !== villains;const [filteredVillains,setFilteredVillains] =useState(deferredVillains);constnavigate=useNavigate();const [isPending,startTransition] =useTransition();// needed to refresh the list after deleting a villainuseEffect(() =>setFilteredVillains(deferredVillains), [deferredVillains]);// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectVillain= (villainId:string) => () => {constvillain=deferredVillains.find((h:Villain) =>h.id === villainId);navigate(`/villains/edit-villain/${villain?.id}?name=${villain?.name}&description=${villain?.description}` ); };typeVillainProperty=|Villain["name"]|Villain["description"]|Villain["id"];/** returns a boolean whether the villain properties exist in the search field */constsearchExists= (searchProperty:VillainProperty, searchField:string) =>String(searchProperty).toLowerCase().indexOf(searchField.toLowerCase()) !==-1;/** given the data and the search field, returns the data in which the search field exists */constsearchProperties= (searchField:string, data:Villain[]) => [...data].filter((item:Villain) =>Object.values(item).find((property:VillainProperty) =>searchExists(property, searchField) ) );/** filters the villains data to see if the any of the properties exist in the list */consthandleSearch= (data:Villain[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnstartTransition(() =>setFilteredVillains(searchProperties(searchField, data)) ); };return ( <divstyle={{ opacity: isPending ?0.5:1, color: isStale ?"dimgray":"black", }} > {deferredVillains.length>0&& ( <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(deferredVillains)} /> </div> )} <uldata-cy="villain-list"className="list"> {filteredVillains.map((villain, index) => ( <lidata-cy={`villain-list-item-${index}`} key={villain.id}> <divclassName="card"> <CardContentname={villain.name}description={villain.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteVillain(villain)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectVillain(villain.id)} /> </footer> </div> </li> ))} </ul> </div> );}
Summary & Takeaways
Context api lets us pass a value deep into the component tree, without explicitly threading it through every component.
Use a custom hook, manage state and effects within the hook, and only return the values that the components need which may be a value and a setValue.
With cy.intercept and MSW we checked that a network request goes out versus checking that the operation caused a hook to be called. Consequently changing the hooks had no impact on the tests, or the functionality. This is why we want to test at a slightly higher level of abstraction, and why we want to verify the consequences of the implementation vs the implementation details themselves.