Searching TVMaze

Fetch matching shows when Search is pressed, and show loading, error, or cards.

The handler can be async

Here is the watchlist as you left it. Type a name, press Search, and a line reads Looking for: plus that name. Nothing talks to the network yet.

import { useState } from "react";
import "./styles.css";
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: "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 [shows, setShows] = useState(start);
  const [query, setQuery] = useState("");
  const [submittedQuery, setSubmittedQuery] = useState("");

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

  function handleSearch() {
    setSubmittedQuery(query);
  }

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

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {shows.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
      />
      {submittedQuery ? (
        <p className="status">Looking for: {submittedQuery}</p>
      ) : null}
      <ShowList shows={shows} onToggle={handleToggle} />
    </div>
  );
}

TVMaze already knows those titles. Promises and async/await taught fetch and await. A submit handler can be async too.

This is the same fetch as the refresher, called from the click.

Mark handleSearch async. Call event.preventDefault() first, then await fetch(...). Wrap the query with encodeURIComponent. Check response.ok.

import { useState } from "react";
import "./styles.css";
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: "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 [shows, setShows] = useState(start);
  const [query, setQuery] = useState("");
  const [submittedQuery, setSubmittedQuery] = useState("");

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

  async function handleSearch(event) {
    event.preventDefault();
    const trimmed = query.trim();
    const response = await fetch(
      "https://api.tvmaze.com/search/shows?q=" +
        encodeURIComponent(trimmed)
    );
    if (!response.ok) {
      setSubmittedQuery("Could not load shows.");
      return;
    }
    const data = await response.json();
    setSubmittedQuery("Found " + data.length + " shows");
  }

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

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {shows.length} shows · {watched} watched
        </p>
      </header>
      <SearchForm
        query={query}
        onQueryChange={setQuery}
        onSearch={handleSearch}
      />
      {submittedQuery ? (
        <p className="status">{submittedQuery}</p>
      ) : null}
      <ShowList shows={shows} onToggle={handleToggle} />
    </div>
  );
}

Type severance and press Search. The line waits, then reads how many shows came back. The three cards stay.

Idle, loading, error, success

A request is not a value. It might still be going, or it might fail.

Keep status in state, starting at "idle". The first paint is idle: no request has run. On submit, set "loading", then "success" or "error".

Disable Search while status === "loading", so a slow reply cannot easily pile up.

If the box is blank after trim(), set a short message and return. Do not call fetch.

import { useState } from "react";
import "./styles.css";
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: "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 [shows, setShows] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("idle");

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

  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);
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

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

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {shows.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" ? (
        <p className="status">Found {results.length} shows</p>
      ) : null}
      <ShowList shows={shows} onToggle={handleToggle} />
    </div>
  );
}

Press Search. The button waits until the reply lands. Clear the box and press Search again. The line asks for a name, and no request runs.

Map the payload

TVMaze does not send the shape ShowCard expects. Each hit is { score, show }. The title is show.name, not title. Some shows have no poster, so show.image?.medium uses ?. from Working with Objects.

Put a mapper in /show.js. Call it with item.show. Draw a Results row. Do not add those cards to the watchlist yet.

If onToggle is missing, ShowCard hides the checkbox. Results are display-only. When show.image is a string, render it inside the still. Keep the gradient class as a fallback.

Do not put summary HTML into the page.

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: "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 [shows, setShows] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("idle");

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

  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 = shows.filter((show) => show.watched).length;

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {shows.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}
      {status === "success" && results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList shows={results} />
        </div>
      ) : null}
      <ShowList shows={shows} onToggle={handleToggle} />
    </div>
  );
}

Search severance. Matching cards appear under Results. The three saved cards stay below, with Watched checkboxes.

No matches

A reply can be empty. That is still "success", not "error". When results.length === 0, show No shows for that name.

Try zzzznotashow in the sandbox above.

Common mistakes

  • Fetching during render. fetch in the component body runs every time React draws. Put it in the submit handler.
  • Skipping response.ok. fetch only rejects when the network fails. A 404 is still a reply. Check response.ok, as in Promises and async/await.
  • Reading results[0] while idle. results starts as []. results[0].show.name throws before anyone searches.

Exercise

Search is wired to TVMaze, but matching cards never appear. The seed list is fine on first paint.

Type severance and press Search. You should see a Results row of cards, and the three saved shows still below.

On first paint the page should still show:

My watchlist
Search
The Bear
Severance
Andor

Hint: each item in the payload is { score, show }. Pass the inner show to fromTvmaze.

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: "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 [shows, setShows] = useState(start);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("idle");

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

  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)));
      setStatus("success");
    } catch {
      setStatus("error");
    }
  }

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

  return (
    <div className="page">
      <header className="header">
        <h1>My watchlist</h1>
        <p className="count">
          {shows.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}
      {status === "success" && results.length > 0 ? (
        <div className="results">
          <p className="section-title">Results</p>
          <ShowList shows={results} />
        </div>
      ) : null}
      <ShowList shows={shows} onToggle={handleToggle} />
    </div>
  );
}