ch18-suspense-errBoundary-concurrency

In order to make better sense out of the new features, we start by adding 3 components - ErrorComp, PageSpinner, Spinner - and a new search filter feature for HeroList. We will need the components for Suspense and ErrorBoundary use cases.

// src/components/ErrorComp.cy.tsx
import ErrorComp from "./ErrorComp";
import "../styles.scss";

describe("ErrorComp", () => {
  it("should render error", () => {
    cy.mount(<ErrorComp />);
    cy.getByCy("error").should("be.visible");
  });
});
// src/components/ErrorComp.test.tsx
import ErrorComp from "./ErrorComp";
import { render, screen } from "@testing-library/react";

describe("ErrorComp", () => {
  it("should render error", async () => {
    render(<ErrorComp />);
    expect(await screen.findByTestId("error")).toBeVisible();
  });
});
// src/components/ErrorComp.tsx
export default function ErrorComp() {
  return (
    <>
      <h1 data-cy="error">Something went wrong!</h1>
      <p>Try reloading the page.</p>
    </>
  );
}
// src/components/Spinner.cy.tsx
import Spinner from "./Spinner";
import "../styles.scss";

describe("Spinner", () => {
  it("should render a spinner", () => {
    cy.mount(<Spinner />);
    cy.getByCy("spinner").should("be.visible");
  });
});
// src/components/Spinner.test.tsx
import Spinner from "./Spinner";
import { render, screen } from "@testing-library/react";

describe("Spinner", () => {
  it("should render a spinner", async () => {
    render(<Spinner />);
    await screen.findByTestId("spinner");
  });
});
// src/components/Spinner.tsx
import React from "react";
import { FaSpinner } from "react-icons/fa";

export default function Spinner(
  props: JSX.IntrinsicAttributes &
    React.ClassAttributes<HTMLSpanElement> &
    React.HTMLAttributes<HTMLSpanElement>
) {
  return (
    <span {...props}>
      <FaSpinner className="icon-loading" data-cy="spinner" />
    </span>
  );
}
// src/components/PageSpinner.cy.tsx
import PageSpinner from "./PageSpinner";
import "../styles.scss";

describe("PageSpinner", () => {
  it("should render the page spinner", () => {
    cy.mount(<PageSpinner />);
    cy.getByCyLike("page-spinner").should("be.visible");
  });
});
// src/components/PageSpinner.test.tsx
import PageSpinner from "./PageSpinner";
import { render, screen } from "@testing-library/react";

describe("PageSpinner", () => {
  it("should render a PageSpinner", async () => {
    render(<PageSpinner />);
    await screen.findByTestId("page-spinner");
  });
});
// src/components/PageSpinner.tsx
import Spinner from "./Spinner";

export default function PageSpinner() {
  return (
    <p className="page-loading" data-cy="page-spinner">
      <Spinner />
    </p>
  );
}

Search-filter for HeroList

We want a new feature for HeroList which will search and filter the heroes by their names or descriptions, so that later we can have a use case for the new React 18 hooks useTransition and useDeferredValue. Let's add a new test for it to HeroList.cy.tsx. When we type a hero name or description to search, we should only get that hero in the list. We can also move the mount to a beforeEach test hook since it is the same to all tests in this file (Red 1).

// src/heroes/HeroList.cy.tsx
import { BrowserRouter } from "react-router-dom";
import HeroList from "./HeroList";
import "../styles.scss";
import heroes from "../../cypress/fixtures/heroes.json";

describe("HeroList", () => {
  beforeEach(() => {
    cy.mount(
      <BrowserRouter>
        <HeroList
          heroes={heroes}
          handleDeleteHero={cy.stub().as("handleDeleteHero")}
        />
      </BrowserRouter>
    );
  });

  it("should render the hero layout", () => {
    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");
      });
  });

  it.only("should search and filter hero by name and description", () => {
    cy.getByCy("search").type(heroes[0].name);
    cy.getByCyLike("hero-list-item")
      .should("have.length", 1)
      .contains(heroes[0].name);

    cy.getByCy("search").clear().type(heroes[2].description);
    cy.getByCyLike("hero-list-item")
      .should("have.length", 1)
      .contains(heroes[2].description);
  });

  context("handleDelete, handleEdit", () => {
    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.location("pathname").should("eq", "/heroes/edit-hero/" + heroes[0].id);
    });
  });
});

When typing into the search field, we want to filter the heroes data to see if a name or description exists in the list. We already get the heroes data as a prop, which we can manage as state with useState:

const [filteredList, setFilteredList] = useState(heroes)

Now we have to set that state with the filtering logic. Here are two functions that help us do that:

type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

/** returns a boolean whether the hero properties exist in the search field */
const searchExists = (searchProperty: HeroProperty, 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 */
const searchProperties = (searchField: string, data: Hero[]) =>
  [...data].filter((item: Hero) =>
    Object.values(item).find((property: HeroProperty) =>
      searchExists(property, searchField)
    )
  );

/** filters the heroes data to see if the name or the description exists in the list */
const handleSearch =
  (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
    const searchField = event.target.value;

    return setFilteredHeroes(searchProperties(searchField, data));
  };

Instead of rendering the list by heroes.map, we use filteredHeroes, which will get set by the handleSearch upon a change event.

// src/heroes/HeroList.tsx
import { 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, useState } from "react";
import { Hero } from "models/Hero";

type HeroListProps = {
  heroes: Hero[];
  handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
  const [filteredHeroes, setFilteredHeroes] = useState(heroes);
  const navigate = useNavigate();

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectHero = (heroId: string) => () => {
    const hero = heroes.find((h: Hero) => h.id === heroId);
    navigate(
      `/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
    );
  };

  type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

  /** returns a boolean whether the hero properties exist in the search field */
  const searchExists = (searchProperty: HeroProperty, 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 */
  const searchProperties = (searchField: string, data: Hero[]) =>
    [...data].filter((item: Hero) =>
      Object.values(item).find((property: HeroProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the heroes data to see if the name or the description exists in the list */
  const handleSearch =
    (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return setFilteredHeroes(searchProperties(searchField, data));
    };

  return (
    <div>
      <div className="card-content">
        <span>Search </span>
        <input data-cy="search" onChange={handleSearch(heroes)} />
      </div>
      &nbsp;
      <ul data-cy="hero-list" className="list">
        {filteredHeroes.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(hero)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectHero(hero.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

We added a feature to a component that get used in other components. When adding major features, it is important to execute the CT as well as e2e test suites entirely to ensure there are no regressions; yarn cy:run-ct, yarn cy:run-e2e. In theory, nothing should go wrong. There are no component errors. delete-hero e2e test however is not clearing the newly added hero upon delete; we have to refresh to render the updated hero list. Although they have a reputation of being "brittle", well-written, stable e2e tests have a high fault-finding capability, catching the defects that are not realized in a smaller focus.

To address the defect, we have to render the HeroList whenever heroes change. That is achieved with useEffect and the state we rely on - heroes - in the dependency array (Green 1).

useEffect(() => setFilteredHeroes(heroes), [heroes])

// src/heroes/HeroList.tsx
import { 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,
  startTransition,
  useEffect,
  useState,
} from "react";
import { Hero } from "models/Hero";

type HeroListProps = {
  heroes: Hero[];
  handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
  const [filteredHeroes, setFilteredHeroes] = useState(heroes);
  const navigate = useNavigate();

  // needed to refresh the list after deleting a hero
  useEffect(() => setFilteredHeroes(heroes), [heroes]);

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectHero = (heroId: string) => () => {
    const hero = heroes.find((h: Hero) => h.id === heroId);
    navigate(
      `/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
    );
  };

  type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

  /** returns a boolean whether the hero properties exist in the search field */
  const searchExists = (searchProperty: HeroProperty, 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 */
  const searchProperties = (searchField: string, data: Hero[]) =>
    [...data].filter((item: Hero) =>
      Object.values(item).find((property: HeroProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the heroes data to see if the name or the description exists in the list */
  const handleSearch =
    (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return setFilteredHeroes(searchProperties(searchField, data));
    };

  return (
    <div>
      <div className="card-content">
        <span>Search </span>
        <input data-cy="search" onChange={handleSearch(heroes)} />
      </div>
      &nbsp;
      <ul data-cy="hero-list" className="list">
        {filteredHeroes.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(hero)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectHero(hero.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Concurrency with useDeferredValue & useTransition

The concept of Concurrency is new in React 18. While multiple state updates are occurring simultaneously, Concurrency refers to certain state updates having less priority over others, for the purpose of optimizing UI responsiveness. useDeferredValue & useTransition hooks are new in React 18. They are not needed in our application, but we will show where they may fit if we were loading vast amounts of data on a slow connection.

useTransition() can be used to specify which state updates have a lower priority than all other state updates.

const [isPending, startTransition] = useTransition()

isPending is a boolean value, signifying if the low-priority state update is still pending.

startTransition is a function that we wrap around the low-priority state update.

In our HeroList component, setFilteredHeroes can be treated as a low priority state update. This would make the user experience so that the search filter input stays responsive while the list is still loading, in case the hero list is very large and the network is very slow.

The first change is in the return segment of handleSearch. startTransition wraps a function that returns setFilteredHeroes.

return startTransition(() =>
  setFilteredHeroes(
    [...data].filter(
      ({ name, description }: Hero) =>
        searchExists(searchField, name) ||
        searchExists(searchField, description)
    )
  )
);

We can reduce the opacity of the entire component in case the transition isPending:

 return (
    <div
      style={{
        opacity: isPending ? 0.5 : 1,
      }}
    >
      <div className="card-content">
   	  ...
      </div>
     ...
   </div>

Here are the useTransition updates to the HeroList component.

// src/heroes/HeroList.tsx
import { 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,
} from "react";
import { Hero } from "models/Hero";

type HeroListProps = {
  heroes: Hero[];
  handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
  const [filteredHeroes, setFilteredHeroes] = useState(heroes);
  const navigate = useNavigate();
  const [isPending, startTransition] = useTransition();

  // needed to refresh the list after deleting a hero
  useEffect(() => setFilteredHeroes(heroes), [heroes]);

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectHero = (heroId: string) => () => {
    const hero = heroes.find((h: Hero) => h.id === heroId);
    navigate(
      `/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
    );
  };

  type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

  /** returns a boolean whether the hero properties exist in the search field */
  const searchExists = (searchProperty: HeroProperty, 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 */
  const searchProperties = (searchField: string, data: Hero[]) =>
    [...data].filter((item: Hero) =>
      Object.values(item).find((property: HeroProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the heroes data to see if the name or the description exists in the list */
  const handleSearch =
    (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return startTransition(() =>
        setFilteredHeroes(searchProperties(searchField, data))
      );
    };

  return (
    <div
      style={{
        opacity: isPending ? 0.5 : 1,
      }}
    >
      <div className="card-content">
        <span>Search </span>
        <input data-cy="search" onChange={handleSearch(heroes)} />
      </div>
      &nbsp;
      <ul data-cy="hero-list" className="list">
        {filteredHeroes.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(hero)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectHero(hero.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

With useTransition we have full control over the low priority code. Sometimes, we might not, for example if the data is coming in from the outside as a prop or if it is coming from external code. In such cases we can utilize useDeferredValue. In contrast to wrapping the state updating code with useTransition , with useDeferredValue we wrap the final value that got impacted. The end results of useTransition & useDeferredValue are the same; we tell React what the lower priority state updates are.

If you have access to the state updating code, prefer useTransition. If you do not have access to the code but only to the final value, utilize useDeferredValue.

In our HeroList component, the hero data is coming in as a prop, which is a good candidate for useDeferredValue.

export default function HeroList({heroes, handleDeleteHero}: HeroListProps) {
  const deferredHeroes = useDeferredValue(heroes)
  const isStale = deferredHeroes !== heroes
  const [filteredHeroes, setFilteredHeroes] = useState(deferredHeroes)

We can utilize the isStale value in the CSS like so:

<div
  style={{
    opacity: isPending ? 0.5 : 1,
    color: isStale ? 'dimgray' : 'black',
  }}

Here is the updated HeroList component (Refactor 1):

// src/heroes/HeroList.tsx
import { 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 { Hero } from "models/Hero";

type HeroListProps = {
  heroes: Hero[];
  handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
  const deferredHeroes = useDeferredValue(heroes);
  const isStale = deferredHeroes !== heroes;
  const [filteredHeroes, setFilteredHeroes] = useState(deferredHeroes);
  const navigate = useNavigate();
  const [isPending, startTransition] = useTransition();

  // needed to refresh the list after deleting a hero
  useEffect(() => setFilteredHeroes(deferredHeroes), [deferredHeroes]);

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectHero = (heroId: string) => () => {
    const hero = deferredHeroes.find((h: Hero) => h.id === heroId);
    navigate(
      `/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
    );
  };

  type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

  /** returns a boolean whether the hero properties exist in the search field */
  const searchExists = (searchProperty: HeroProperty, 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 */
  const searchProperties = (searchField: string, data: Hero[]) =>
    [...data].filter((item: Hero) =>
      Object.values(item).find((property: HeroProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the heroes data to see if the name or the description exists in the list */
  const handleSearch =
    (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return startTransition(() =>
        setFilteredHeroes(searchProperties(searchField, data))
      );
    };

  return (
    <div
      style={{
        opacity: isPending ? 0.5 : 1,
        color: isStale ? "dimgray" : "black",
      }}
    >
      <div className="card-content">
        <span>Search </span>
        <input data-cy="search" onChange={handleSearch(deferredHeroes)} />
      </div>
      &nbsp;
      <ul data-cy="hero-list" className="list">
        {filteredHeroes.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(hero)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectHero(hero.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

The conditional rendering gives another clue; do we need a search bar when there is no data? Let's add that feature, starting with a failing test. We will rearrange HeroList.cy.tsx a bit so that we can capture the test in two contexts; mount without hero data, and mount with hero data (Red 2).

// src/heroes/HeroList.cy.tsx
import { BrowserRouter } from "react-router-dom";
import HeroList from "./HeroList";
import "../styles.scss";
import heroes from "../../cypress/fixtures/heroes.json";

describe("HeroList", () => {
  it("no heroes should not display a list nor search bar", () => {
    cy.mount(
      <BrowserRouter>
        <HeroList
          heroes={[]}
          handleDeleteHero={cy.stub().as("handleDeleteHero")}
        />
      </BrowserRouter>
    );

    cy.getByCy("hero-list").should("exist");
    cy.getByCyLike("hero-list-item").should("not.exist");
    cy.getByCy("search").should("not.exist");
  });

  context("with heroes in the list", () => {
    beforeEach(() => {
      cy.mount(
        <BrowserRouter>
          <HeroList
            heroes={heroes}
            handleDeleteHero={cy.stub().as("handleDeleteHero")}
          />
        </BrowserRouter>
      );
    });

    it("should render the hero layout", () => {
      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");
        });
    });

    it("should search and filter hero by name and description", () => {
      cy.getByCy("search").type(heroes[0].name);
      cy.getByCyLike("hero-list-item")
        .should("have.length", 1)
        .contains(heroes[0].name);

      cy.getByCy("search").clear().type(heroes[2].description);
      cy.getByCyLike("hero-list-item")
        .should("have.length", 1)
        .contains(heroes[2].description);
    });

    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.location("pathname").should("eq", "/heroes/edit-hero/" + heroes[0].id);
    });
  });
});

To satisfy the test, all we need is conditional rendering for the search bar.

{
  deferredHeroes.length > 0 && (
    <div className="card-content">
      <span>Search </span>
      <input data-cy="search" onChange={handleSearch(deferredHeroes)} />
    </div>
  );
}

Here is the HeroList component in its final form (Green 2):

// src/heroes/HeroList.tsx
import { 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 { Hero } from "models/Hero";

type HeroListProps = {
  heroes: Hero[];
  handleDeleteHero: (hero: Hero) => (e: MouseEvent<HTMLButtonElement>) => void;
};

export default function HeroList({ heroes, handleDeleteHero }: HeroListProps) {
  const deferredHeroes = useDeferredValue(heroes);
  const isStale = deferredHeroes !== heroes;
  const [filteredHeroes, setFilteredHeroes] = useState(deferredHeroes);
  const navigate = useNavigate();
  const [isPending, startTransition] = useTransition();

  // needed to refresh the list after deleting a hero
  useEffect(() => setFilteredHeroes(deferredHeroes), [deferredHeroes]);

  // currying: the outer fn takes our custom arg and returns a fn that takes the event
  const handleSelectHero = (heroId: string) => () => {
    const hero = deferredHeroes.find((h: Hero) => h.id === heroId);
    navigate(
      `/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}`
    );
  };

  type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];

  /** returns a boolean whether the hero properties exist in the search field */
  const searchExists = (searchProperty: HeroProperty, 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 */
  const searchProperties = (searchField: string, data: Hero[]) =>
    [...data].filter((item: Hero) =>
      Object.values(item).find((property: HeroProperty) =>
        searchExists(property, searchField)
      )
    );

  /** filters the heroes data to see if the any of the properties exist in the list */
  const handleSearch =
    (data: Hero[]) => (event: ChangeEvent<HTMLInputElement>) => {
      const searchField = event.target.value;

      return startTransition(() =>
        setFilteredHeroes(searchProperties(searchField, data))
      );
    };

  return (
    <div
      style={{
        opacity: isPending ? 0.5 : 1,
        color: isStale ? "dimgray" : "black",
      }}
    >
      {deferredHeroes.length > 0 && (
        <div className="card-content">
          <span>Search </span>
          <input data-cy="search" onChange={handleSearch(deferredHeroes)} />
        </div>
      )}
      &nbsp;
      <ul data-cy="hero-list" className="list">
        {filteredHeroes.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(hero)}
                />
                <ButtonFooter
                  label="Edit"
                  IconClass={FaEdit}
                  onClick={handleSelectHero(hero.id)}
                />
              </footer>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Suspense & ErrorBoundary

The setup

To manage the amount of code loaded upon application startup - the initial bundle - we can use code splitting which aims to load the app's code in chunks in order to improve UI responsiveness. In React, Suspense and lazy loading are used to accomplish code splitting. They are usually talked about together with ErrorBoundary, because Suspense and ErrorBoundary components let us decouple the loading and error UI from individual components. Here are the key ideas:

  • While loading show the Suspense component, if error show the ErrorBoundary component, if success show the component we want to render.

  • Use React.lazy to load components only when they are first rendered.

Converting a component to a lazy component with the lazy function:

// staticly imported component
import Heroes from "heroes/Heroes";

// lazy loaded component
const Heroes = lazy(() => import("heroes/Heroes"));

Use the Suspense and ErrorBoundary components to wrap UI that contains one or more lazy components in its tree. Here is how they work together at a high level:

<ErrorBoundary fallback={<ErrorComp />}>
  <Suspense fallback={<PageSpinner />}>
    <Routes>
      ..
      <lazy-loaded-components />
      ..
    </Routes>
  </Suspense>
</ErrorBoundary>

For setup, install react-error-boundary with yarn add react-error-boundaryand update App.tsx like so:

// src/App.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { ErrorBoundary } from "react-error-boundary";
import HeaderBar from "components/HeaderBar";
import NavBar from "components/NavBar";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import "./styles.scss";
const Heroes = lazy(() => import("heroes/Heroes"));
const NotFound = lazy(() => import("components/NotFound"));
const About = lazy(() => import("About"));

const queryClient = new QueryClient();

function App() {
  return (
    <BrowserRouter>
      <HeaderBar />
      <div className="section columns">
        <NavBar />
        <main className="column">
          <QueryClientProvider client={queryClient}>
            <ErrorBoundary fallback={<ErrorComp />}>
              <Suspense fallback={<PageSpinner />}>
                <Routes>
                  <Route path="/" element={<Navigate replace to="/heroes" />} />
                  <Route path="/heroes/*" element={<Heroes />} />
                  <Route path="/about" element={<About />} />
                  <Route path="*" element={<NotFound />} />
                </Routes>
              </Suspense>
            </ErrorBoundary>
          </QueryClientProvider>
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;

Update the useGetHeroes hook to take a 3rd argument for configuration. Passing {suspense: true} will enable Suspense mode in react-query's useQuery.

// 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"), {
    suspense: true,
  });

  return {
    heroes: query.data,
    status: query.status,
    getError: query.error,
  };
};

Tests for error cases

HeroDetail component

Now we can begin writing failing tests for error edge cases. Where do we start? Any component test that is covering the positive cases, spying on or stubbing the network with cy.intercept() is a good candidate . Those are HeroDetail and Heroes component tests.

Add a test to HeroList component test for a non-200 scenario. We use a delay option to be able to see the spinner (Red 3).

it("should handle Save", () => {
  cy.intercept("POST", "*", { statusCode: 200 }).as("postHero");
  cy.getByCy("save-button").click();
  cy.wait("@postHero");
});

// the new test
it("should handle non-200 Save", () => {
  cy.intercept("POST", "*", { statusCode: 400, delay: 100 }).as("postHero");
  cy.getByCy("save-button").click();
  cy.getByCy("spinner");
});
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import "../styles.scss";

describe("HeroDetail", () => {
  let queryClient: QueryClient;
  context("handleSave, handleCancel", () => {
    beforeEach(() => {
      queryClient = new QueryClient();
      cy.mount(
        <QueryClientProvider client={queryClient}>
          <BrowserRouter>
            <HeroDetail />
          </BrowserRouter>
        </QueryClientProvider>
      );
    });

    it("should handle Save", () => {
      cy.intercept("POST", "*", { statusCode: 200 }).as("postHero");
      cy.getByCy("save-button").click();
      cy.wait("@postHero");
    });

    it.only("should handle non-200 Save", () => {
      cy.intercept("POST", "*", { statusCode: 400, delay: 100 }).as("postHero");
      cy.getByCy("save-button").click();
      cy.getByCy("spinner");
    });

    it("should handle Cancel", () => {
      cy.getByCy("cancel-button").click();
      cy.location("pathname").should("eq", "/heroes");
    });
  });

  context("handleNameChange, handleDescriptionChange", () => {
    beforeEach(() => {
      queryClient = new QueryClient();
      cy.mount(
        <QueryClientProvider client={queryClient}>
          <BrowserRouter>
            <HeroDetail />
          </BrowserRouter>
        </QueryClientProvider>
      );
    });

    it("should handle name change", () => {
      const newHeroName = "abc";
      cy.getByCy("input-detail-name").type(newHeroName);

      cy.findByDisplayValue(newHeroName).should("be.visible");
    });

    it("should handle description change", () => {