9 min read

Tutorial: Create a movie search page

💡
This is a detailed tutorial that solves the Movie search page exercise.
If you haven't already, try to solve it yourself first before reading the solution!

To implement the “Movie search page”, you’ll need to tackle the following:

  • setup an account with “The Movie Database” so you can have access to the API
  • create a page to show a list of movies
  • hook in a search box where the user can input his search query

Let’s go over these steps by step.

Step 1: Getting access to the API

The exercise instructions pointed to an endpoint that you should use when doing the searches - https://developers.themoviedb.org/3/search/search-movies. But can this endpoint be accessed by anyone? Let’s try!

Easiest way is to just open the URL in the search bar:

Screenshot2023-01-08at130842

Turns out we cannot use the endpoint without an authentication key, so let’s get one.

The instructions on the Introduction page guide us through the process:

Screenshot2023-01-08at130136

The example provided uses the v3 auth, so let’s use this way of accessing the API.

Pasting the updated URL with the api_key in the browser shows that the auth error is gone! So it means we can now use the endpoint. But there’s still a problem - it expects a search query - we need to actually search for something!

Screenshot2023-01-08at130933

To check, I just passed in “Amelie” as the query, and it now works!

Screenshot2023-01-08at130720

Step 2: Displaying the search results

Now that we have access to the API, let’s show the search results in our React app. To keep things simple, we’ll hardcode a search query for now.

The instructions mentioned we should use the axios library for data fetching, so let’s install that first:

npm install axios

Then, let’s fetch the data and display some basic info about the results:

import { useState, useEffect } from "react";
import axios from "axios";

const MovieSearchPage = () => {
  const [movies, setMovies] = useState([]);

  useEffect(() => {
    axios
      .get("https://api.themoviedb.org/3/search/movie", {
        params: {
          api_key: “12345”,
          query: "amelie",
        },
      })
      .then((response) => {
        setMovies(response.data.results);
      });
  }, []);

  return (
    <div>
      <h2>Movie search page</h2>
      <ul>
        {movies.map((movie) => (
          <li key={movie.id}>{movie.original_title}</li>
        ))}
      </ul>
    </div>
  );
};

export default MovieSearchPage;

And it works! We can now fetch data from “The Movie Database” and display it!

But there’s an issue with the code above - the API key is hardcoded in the code, which is a bad practice, as you should not commit it to the Git repository.

In the next steps we’ll extract the API key from the code and inject it as a build time environment variable using dotenv.

Screenshot2023-01-08at133911

Step 3 - Setting the API key as a build time environment variable using `dotenv`

The most common way to extract configuration variables our of the codebase is to move them to a .env file. Then, the values from this file can be read at build time and injected in the code.

To work with .env files in React you can use the dotenv package:

npm install dotenv --save

Then, create a .env file in the root of the project and add the API key there:

// .env
THE_MOVIE_DATABASE_API_KEY=1234

Now, we’ll need to make it available at build time - this means we need to hook dotenv and Webpack. The easiest way to achieve this is using the dotenv-webpack plugin:

npm install dotenv-webpack --save-dev

Update your Webpack config with the new plugin:

...
const Dotenv = require("dotenv-webpack");

module.exports = {
  ...
  plugins: [
    ...,
    new Dotenv(),
  ],
};

Lastly, update the data fetching code to read the API key from the config variable instead of having it hardcoded:

axios
    .get("https://api.themoviedb.org/3/search/movie", {
      params: {
       api_key: process.env.THE_MOVIE_DATABASE_API_KEY,
       query: "amelie",
      },
    })

To prevent the .env file itself from being commited to git, you should add it to your .gitignore file.

💡
You might have noticed that even if the key is now no longer in the codebase, it will still be public for everyone to see in the final compiled bundle.

In this case, this is not a problem, as the resources we can access are just public resources and everyone can get their own key for free, so there's a low risk involved in it being compromised. But in general, you want to think twice before including a key in your frontend bundle - learn more on this topic in my blog post on best practices for using API keys in the frontend.

Step 4: Show movie details in list view

In the previous step, we only displayed the movies’ titles, to make sure the data fetching works.

But it would be nice to also show the poster image.

Screenshot2023-01-11at075311

To inspect what properties are available for each Movie, you can just use good old console.log :)

Screenshot2023-01-11at075554

We can use the poster_path property to read the path to the poster image. But it will not work directly, as it only contains the image name - the docs explain how to compose the URL to the image.

Let’s create a new component - Movie - for displaying the details of a movie.

// movie.jsx
const Movie = ({ movie }) => {
  return (
    <div
      style={{ border: "1px solid gray", width: "150px", textAlign: "center" }}
    >
      <h3>{movie.original_title}</h3>
      {movie.poster_path && (
        <img
          src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`}
          style={{ width: "100px" }}
        />
      )}
    </div>
  );
};

export default Movie;

Then, update the main component to use the new Movie component to display each movie:

// movie-search-page.jsx
...
return (
    <div>
      <h2>Movie search page</h2>
      <div style={{ display: "flex", flexWrap: "wrap" }}>
        {movies.map((movie) => (
          <Movie key={movie.id} movie={movie} />
        ))}
      </div>
    </div>
  );

That’s it, we now have a complete search results view.

Step 5: Allow users to search for movies

In the previous step we built the movie search results list page - which shows the details for a list of movies. But our search query was hardcoded - always showing Amelie.

Let’s update the code to allow users to search for whatever movie they want.

Screenshot2023-01-11at081532

First, we’ll need to add a search input and a search button.

Then, when the user clicks “Search”, we’ll need to update the search results.

Here is the updated search page component:

import { useState, useEffect } from "react";
import axios from "axios";
import Movie from "./movie";

const MovieSearchPage = () => {
  const [movies, setMovies] = useState([]);
  const [searchQuery, setSearchQuery] = useState("");

  const handleMovieSearch = (e) => {
    e.preventDefault();
    axios
      .get("https://api.themoviedb.org/3/search/movie", {
        params: {
          api_key: process.env.THE_MOVIE_DATABASE_API_KEY,
          query: searchQuery,
        },
      })
      .then((response) => {
        setMovies(response.data.results);
      });
  };

  return (
    <div>
      <h2>Movie search page</h2>
      <form onSubmit={handleMovieSearch} style={{ marginBottom: "30px" }}>
        <input
          type="text"
          placeholder="Search for a movie"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
        />
        <button type="submit">Search</button>
      </form>
      <div style={{ display: "flex", flexWrap: "wrap" }}>
        {movies.map((movie) => (
          <Movie key={movie.id} movie={movie} />
        ))}
      </div>
    </div>
  );
};

export default MovieSearchPage;

A few things worth pointing out:

  • the code to search for the movies was moved from the useEffect to an event handler - since now we only search when the user clicks “Search”
  • the input and search button are wrapped in an HTML form element - this allows user to search either by clicking the “Search” button or just pressing “Enter”
  • on submit, we call e.preventDefault - this is useful to prevent the page from reloading - which is the default browser behaviour when submitting forms (useful for server side rendered apps, but not so useful for SPAs)

If everything works, you should now be able to search for movies:

Screenshot2023-01-11at081542

Step 6: Error handling

Let’s handle the cases when the API returns an error.

An easy way to simulate an error is to disable the Internet using Chrome DevTools.

Go to the Network tab and select “Offline” in the network dropdown:

Screenshot2023-01-11at082241

If you then try to search for something, you’ll see errors both in the Network API calls list and the Console:

Screenshot2023-01-11at082324

But our UI does not update - users don’t now there is a problem! So let’s add a very basic error message if an error is thrown:

Screenshot2023-01-11at082849

To achieve this:

  • we’ll catch the axios error in a .catch statement
  • we’ll save the error message in a state variable
  • we’ll clear the error message on every new request

Here is the updated code:

import { useState } from "react";
import axios from "axios";
import Movie from "./movie";

const MovieSearchPage = () => {
  const [movies, setMovies] = useState([]);
  const [searchQuery, setSearchQuery] = useState("");
  const [error, setError] = useState(null);

  const handleMovieSearch = (e) => {
    e.preventDefault();
    setError(null);
    axios
      .get("https://api.themoviedb.org/3/search/movie", {
        params: {
          api_key: process.env.THE_MOVIE_DATABASE_API_KEY,
          query: searchQuery,
        },
      })
      .then((response) => {
        setMovies(response.data.results);
      })
      .catch(() => {
        setError(
          "There was an error loading the movie search results. Please try again later."
        );
      });
  };

  return (
    <div>
      <h2>Movie search page</h2>
      <form onSubmit={handleMovieSearch} style={{ marginBottom: "30px" }}>
        <input
          type="text"
          placeholder="Search for a movie"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
        />
        <button type="submit">Search</button>
      </form>
      {error && <div style={{ color: "red" }}>{error}</div>}
      <div style={{ display: "flex", flexWrap: "wrap" }}>
        {movies.map((movie) => (
          <Movie key={movie.id} movie={movie} />
        ))}
      </div>
    </div>
  );
};

export default MovieSearchPage;

Step 7: Extracting search logic in a custom hook

The app is now feature complete, but it would be nice to extract the logic for searching in a custom hook. We can call it useMovieSearch.

The hook will keep track of the list of movies, the error and the search query.

The main page will only handle the form input and the submit handler.

Since the search query will be tracked in two places, the searchQuery state variable was renamed to searchInputText, to make it more obvious that it’s tracking the controlled input text and nothing more.

Updating the text input value happens on every click, but updating the movie search query only happens on submit.

Since useEffect is called on component mount, even if the search query didn’t change, our app does an extra request to fetch the movies, even before the user input anything. So let’s add a check to the hook, to make sure the search doesn’t happen if the query is null.

// use-movie-search.js

import { useState, useEffect } from "react";
import axios from "axios";

export default function useMovieSearch() {
  const [searchQuery, setSearchQuery] = useState(null);
  const [movies, setMovies] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (searchQuery === null) {
      return;
    }

    setError(null);
    axios
      .get("https://api.themoviedb.org/3/search/movie", {
        params: {
          api_key: process.env.THE_MOVIE_DATABASE_API_KEY,
          query: searchQuery,
        },
      })
      .then((response) => {
        setMovies(response.data.results);
      })
      .catch(() => {
        setError(
          "There was an error loading the movie search results. Please try again later."
        );
      });
  }, [searchQuery]);

  return { movies, error, setSearchQuery };

}

The updated main page will then be:

// movie-search-page.jsx
import { useState } from "react";
import Movie from "./movie";
import useMovieSearch from "./use-movie-search";

const MovieSearchPage = () => {
  const [searchInputText, setSearchInputText] = useState("");
  const { movies, error, setSearchQuery } = useMovieSearch();

  const handleMovieSearch = (e) => {
    e.preventDefault();
    // Update the movie search query when the user clicks "Search"
    setSearchQuery(searchInputText);
  };

  return (
    <div>
      <h2>Movie search page</h2>
      <form onSubmit={handleMovieSearch} style={{ marginBottom: "30px" }}>
        <input
          type="text"
          placeholder="Search for a movie"
          value={searchInputText}
          onChange={(e) => setSearchInputText(e.target.value)}
        />
        <button type="submit">Search</button>
      </form>
      {error && <div style={{ color: "red" }}>{error}</div>}
      <div style={{ display: "flex", flexWrap: "wrap" }}>
        {movies.map((movie) => (
          <Movie key={movie.id} movie={movie} />
        ))}
      </div>
    </div>
  );
};

export default MovieSearchPage;

That’s a wrap!

How is this solution different from your own implementation?

Leave your thoughts or questions in the comments below.