The show details

Select a saved show in state, and load extra fields from TVMaze when you ask.

Click the card

Here is the app as you left it. Slow Horses sits in Results with Add disabled. The saved list has four cards. Clicking a card does nothing extra yet.

import { useState } from "react";
import "./styles.css";
import { fromTvmaze } from "./show";
import SearchForm from "./SearchForm";
import ShowList from "./ShowList";

const start = [
  { id: "s1", title: "The Bear", year: 2022, genre: "Drama", genres: ["Drama"], rating: 8.6, still: "still-warm", watched: false },
  { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  { id: "s2", title: "Severance", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.7, still: "still-cold", watched: false },
  { id: "s3", title: "Andor", year: 2022, genre: "Sci-Fi", genres: ["Sci-Fi"], rating: 8.4, still: "still-dusty", watched: true },
];

export default function App() {
  const [watchlist, setWatchlist] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([
    { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  ]);
  const [status, setStatus] = useState("idle");

  function handleToggle(id) {
    setWatchlist(
      watchlist.map((show) =>
        show.id === id ? { ...show, watched: !show.watched } : show
      )
    );
  }

  function handleAdd(show) {
    if (watchlist.some((s) => s.id === show.id)) {
      return; // already saved
    }
    setWatchlist([...watchlist, { ...show, watched: false }]);
  }

  function handleRemove(id) {
    setWatchlist(watchlist.filter((s) => s.id !== id)); // new array
  }

  async function handleSearch(event) {
    event.preventDefault();
    const trimmed = query.trim();
    if (!trimmed) {
      setResults([]);
      setStatus("empty");
      return;
    }

    setStatus("loading");
    setResults([]);

    try {
      const response = await fetch(
        "https://api.tvmaze.com/search/shows?q=" +
          encodeURIComponent(trimmed)
      );
      if (!response.ok) {
        throw new Error("Request failed");
      }
      const data = await response.json();
      setResults(data.map((item) => fromTvmaze(item.show)));
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  const watched = watchlist.filter((show) => show.watched).length;

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {watchlist.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
        disabled={status === "loading"}
      />
      {status === "loading" ? (
        <p className="status">Searching…</p>
      ) : null}
      {status === "empty" ? (
        <p className="status">Type a show name first.</p>
      ) : null}
      {status === "error" ? (
        <p className="status status-error">
          Could not load shows. Try again.
        </p>
      ) : null}
      {status === "success" && results.length === 0 ? (
        <p className="status">No shows for that name.</p>
      ) : null}
      {results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList
            shows={results}
            onAdd={handleAdd}
            isSaved={(id) => watchlist.some((s) => s.id === id)}
          />
        </div>
      ) : null}
      <ShowList
        shows={watchlist}
        onToggle={handleToggle}
        onRemove={handleRemove}
      />
    </div>
  );
}

A click should pick one saved show. Store that id in App as selectedId. find, from Working with Arrays, reads the object back.

const selected = watchlist.find((s) => s.id === selectedId);

Pass onSelect through ShowList, the same way as Passing handlers down. The panel sits beside the saved cards. Close sets selectedId back to null.

Watched, Add, and Remove sit on the card. Their clicks would also select, unless you stop them. Propagation and defaults covers stopPropagation.

import { useState } from "react";
import "./styles.css";
import { fromTvmaze } from "./show";
import SearchForm from "./SearchForm";
import ShowList from "./ShowList";

const start = [
  { id: "s1", title: "The Bear", year: 2022, genre: "Drama", genres: ["Drama"], rating: 8.6, still: "still-warm", watched: false },
  { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  { id: "s2", title: "Severance", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.7, still: "still-cold", watched: false },
  { id: "s3", title: "Andor", year: 2022, genre: "Sci-Fi", genres: ["Sci-Fi"], rating: 8.4, still: "still-dusty", watched: true },
];

export default function App() {
  const [watchlist, setWatchlist] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([
    { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  ]);
  const [status, setStatus] = useState("idle");
  const [selectedId, setSelectedId] = useState("s1"); // The Bear, so the panel is visible

  function handleToggle(id) {
    setWatchlist(
      watchlist.map((show) =>
        show.id === id ? { ...show, watched: !show.watched } : show
      )
    );
  }

  function handleAdd(show) {
    if (watchlist.some((s) => s.id === show.id)) {
      return;
    }
    setWatchlist([...watchlist, { ...show, watched: false }]);
  }

  function handleRemove(id) {
    setWatchlist(watchlist.filter((s) => s.id !== id));
  }

  function handleSelect(id) {
    setSelectedId(id);
  }

  async function handleSearch(event) {
    event.preventDefault();
    const trimmed = query.trim();
    if (!trimmed) {
      setResults([]);
      setStatus("empty");
      return;
    }

    setStatus("loading");
    setResults([]);

    try {
      const response = await fetch(
        "https://api.tvmaze.com/search/shows?q=" +
          encodeURIComponent(trimmed)
      );
      if (!response.ok) {
        throw new Error("Request failed");
      }
      const data = await response.json();
      setResults(data.map((item) => fromTvmaze(item.show)));
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  const watched = watchlist.filter((show) => show.watched).length;
  const selected = watchlist.find((s) => s.id === selectedId);

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {watchlist.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
        disabled={status === "loading"}
      />
      {status === "loading" ? (
        <p className="status">Searching…</p>
      ) : null}
      {status === "empty" ? (
        <p className="status">Type a show name first.</p>
      ) : null}
      {status === "error" ? (
        <p className="status status-error">
          Could not load shows. Try again.
        </p>
      ) : null}
      {status === "success" && results.length === 0 ? (
        <p className="status">No shows for that name.</p>
      ) : null}
      {results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList
            shows={results}
            onAdd={handleAdd}
            isSaved={(id) => watchlist.some((s) => s.id === id)}
          />
        </div>
      ) : null}
      <div className="saved">
        <ShowList
          shows={watchlist}
          onToggle={handleToggle}
          onRemove={handleRemove}
          onSelect={handleSelect}
          selectedId={selectedId}
        />
        {selected ? (
          <aside className="panel">
            <button
              type="button"
              className="close"
              onClick={() => setSelectedId(null)}
            >
              Close
            </button>
            <h2>{selected.title}</h2>
            <p>{selected.year}</p>
            <p>{selected.genres.join(", ")}</p>
            <p>★ {selected.rating}</p>
          </aside>
        ) : null}
      </div>
    </div>
  );
}

The Bear starts selected so you can see the panel. Title, year, genres, and rating are already on the object. Close hides the panel. Tick Watched: the card stays selected.

Load more

TVMaze can send extra fields. The search payload does not include network, status, or runtime. Load more asks for one show by id — the same URL as Promises and async/await.

fetch("https://api.tvmaze.com/shows/" + selectedId);

Only numeric ids exist on that endpoint. Seed ids like s1 do not. /^[0-9]+$/ is true when every character is a digit. That is the same test as /^\d+$/.

Call fetch from the button's click, as Searching TVMaze does on submit. Disable the button while the request is in flight. Skip summary: it is HTML.

Keep the extra fields in a details object. Hand React a new object, as in Updating objects in state. Clear details when the selected id changes.

import { useState } from "react";
import "./styles.css";
import { fromTvmaze } from "./show";
import SearchForm from "./SearchForm";
import ShowList from "./ShowList";

const start = [
  { id: "s1", title: "The Bear", year: 2022, genre: "Drama", genres: ["Drama"], rating: 8.6, still: "still-warm", watched: false },
  { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  { id: "s2", title: "Severance", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.7, still: "still-cold", watched: false },
  { id: "s3", title: "Andor", year: 2022, genre: "Sci-Fi", genres: ["Sci-Fi"], rating: 8.4, still: "still-dusty", watched: true },
];

export default function App() {
  const [watchlist, setWatchlist] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([
    { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  ]);
  const [status, setStatus] = useState("idle");
  const [selectedId, setSelectedId] = useState("99"); // Slow Horses, so Load more is visible
  const [details, setDetails] = useState(null);
  const [detailsLoading, setDetailsLoading] = useState(false);

  function handleToggle(id) {
    setWatchlist(
      watchlist.map((show) =>
        show.id === id ? { ...show, watched: !show.watched } : show
      )
    );
  }

  function handleAdd(show) {
    if (watchlist.some((s) => s.id === show.id)) {
      return;
    }
    setWatchlist([...watchlist, { ...show, watched: false }]);
  }

  function handleRemove(id) {
    setWatchlist(watchlist.filter((s) => s.id !== id));
  }

  function handleSelect(id) {
    if (id === selectedId) {
      return;
    }
    setSelectedId(id);
    setDetails(null);
  }

  async function handleLoadMore() {
    if (!selectedId || !/^[0-9]+$/.test(selectedId)) {
      return;
    }

    setDetailsLoading(true);
    try {
      const response = await fetch(
        "https://api.tvmaze.com/shows/" + selectedId
      );
      if (!response.ok) {
        throw new Error("Request failed");
      }
      const data = await response.json();
      setDetails({
        network: data.network?.name ?? data.webChannel?.name ?? "Unknown",
        status: data.status ?? "Unknown",
        runtime: data.runtime ?? data.averageRuntime ?? null,
      });
    } catch {
      setDetails(null);
    }
    setDetailsLoading(false);
  }

  async function handleSearch(event) {
    event.preventDefault();
    const trimmed = query.trim();
    if (!trimmed) {
      setResults([]);
      setStatus("empty");
      return;
    }

    setStatus("loading");
    setResults([]);

    try {
      const response = await fetch(
        "https://api.tvmaze.com/search/shows?q=" +
          encodeURIComponent(trimmed)
      );
      if (!response.ok) {
        throw new Error("Request failed");
      }
      const data = await response.json();
      setResults(data.map((item) => fromTvmaze(item.show)));
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  const watched = watchlist.filter((show) => show.watched).length;
  const selected = watchlist.find((s) => s.id === selectedId);

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {watchlist.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
        disabled={status === "loading"}
      />
      {status === "loading" ? (
        <p className="status">Searching…</p>
      ) : null}
      {status === "empty" ? (
        <p className="status">Type a show name first.</p>
      ) : null}
      {status === "error" ? (
        <p className="status status-error">
          Could not load shows. Try again.
        </p>
      ) : null}
      {status === "success" && results.length === 0 ? (
        <p className="status">No shows for that name.</p>
      ) : null}
      {results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList
            shows={results}
            onAdd={handleAdd}
            isSaved={(id) => watchlist.some((s) => s.id === id)}
          />
        </div>
      ) : null}
      <div className="saved">
        <ShowList
          shows={watchlist}
          onToggle={handleToggle}
          onRemove={handleRemove}
          onSelect={handleSelect}
          selectedId={selectedId}
        />
        {selected ? (
          <aside className="panel">
            <button
              type="button"
              className="close"
              onClick={() => {
                setSelectedId(null);
                setDetails(null);
              }}
            >
              Close
            </button>
            <h2>{selected.title}</h2>
            <p>{selected.year}</p>
            <p>{selected.genres.join(", ")}</p>
            <p>★ {selected.rating}</p>
            {/^[0-9]+$/.test(selected.id) ? (
              <button
                type="button"
                onClick={handleLoadMore}
                disabled={detailsLoading}
              >
                {detailsLoading ? "Loading…" : "Load more"}
              </button>
            ) : (
              <p>This one was added by us</p>
            )}
            {details ? <p>{details.network}</p> : null}
            {details ? <p>{details.status}</p> : null}
            {details && details.runtime ? (
              <p>{details.runtime} min</p>
            ) : null}
          </aside>
        ) : null}
      </div>
    </div>
  );
}

Slow Horses is id 99, so Load more is on screen. The extra lines stay empty until you press it. Click The Bear: the button is gone, and the panel says This one was added by us.

Do not fetch on select

Selecting a card only sets selectedId. The panel can already show title and year. Load more is a second click, so the first paint never waits on the network.

Do not call fetch inside handleSelect, and do not call it while App is rendering.

Common mistakes

  • Selected state on ShowCard. Each card has its own memory. Two clicks open two panels. Keep selectedId in App.
  • fetch during render. React would start a request every time it draws. Put it in the Load more handler.
  • Rendering summary as HTML. The string contains tags. Show network, status, and runtime instead.

Exercise

Each saved card keeps its own open flag, so two panels can sit on screen at once. Load more also appears on The Bear.

There should be one panel, owned by App. On first paint it stays hidden. Click The Bear. The panel should show:

The Bear
2022

Load more should not be on that panel. s1 is not a TVMaze id. Click Slow Horses instead; Load more should appear there.

On first paint the page should show:

My watchlist
4 shows · 1 watched
Search
Results
Slow Horses
Add
The Bear
Slow Horses
Severance
Andor
Remove

Hint: selectedId lives in App. Show Load more when /^[0-9]+$/.test(id) is true.

import { useState } from "react";
import "./styles.css";
import { fromTvmaze } from "./show";
import SearchForm from "./SearchForm";
import ShowList from "./ShowList";

const start = [
  { id: "s1", title: "The Bear", year: 2022, genre: "Drama", genres: ["Drama"], rating: 8.6, still: "still-warm", watched: false },
  { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  { id: "s2", title: "Severance", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.7, still: "still-cold", watched: false },
  { id: "s3", title: "Andor", year: 2022, genre: "Sci-Fi", genres: ["Sci-Fi"], rating: 8.4, still: "still-dusty", watched: true },
];

export default function App() {
  const [watchlist, setWatchlist] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([
    { id: "99", title: "Slow Horses", year: 2022, genre: "Thriller", genres: ["Thriller"], rating: 8.4, still: "still-warm", watched: false, image: null },
  ]);
  const [status, setStatus] = useState("idle");

  function handleToggle(id) {
    setWatchlist(
      watchlist.map((show) =>
        show.id === id ? { ...show, watched: !show.watched } : show
      )
    );
  }

  function handleAdd(show) {
    if (watchlist.some((s) => s.id === show.id)) {
      return;
    }
    setWatchlist([...watchlist, { ...show, watched: false }]);
  }

  function handleRemove(id) {
    setWatchlist(watchlist.filter((s) => s.id !== id));
  }

  async function handleSearch(event) {
    event.preventDefault();
    const trimmed = query.trim();
    if (!trimmed) {
      setResults([]);
      setStatus("empty");
      return;
    }

    setStatus("loading");
    setResults([]);

    try {
      const response = await fetch(
        "https://api.tvmaze.com/search/shows?q=" +
          encodeURIComponent(trimmed)
      );
      if (!response.ok) {
        throw new Error("Request failed");
      }
      const data = await response.json();
      setResults(data.map((item) => fromTvmaze(item.show)));
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

  const watched = watchlist.filter((show) => show.watched).length;

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {watchlist.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
        disabled={status === "loading"}
      />
      {status === "loading" ? (
        <p className="status">Searching…</p>
      ) : null}
      {status === "empty" ? (
        <p className="status">Type a show name first.</p>
      ) : null}
      {status === "error" ? (
        <p className="status status-error">
          Could not load shows. Try again.
        </p>
      ) : null}
      {status === "success" && results.length === 0 ? (
        <p className="status">No shows for that name.</p>
      ) : null}
      {results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList
            shows={results}
            onAdd={handleAdd}
            isSaved={(id) => watchlist.some((s) => s.id === id)}
          />
        </div>
      ) : null}
      <div className="saved">
        <ShowList
          shows={watchlist}
          onToggle={handleToggle}
          onRemove={handleRemove}
        />
      </div>
    </div>
  );
}