Build a cool movie database using React Hooks

Fabio Hiroki - Jun 16 '20 - - Dev Community

Introdution

In this article I will show you the project I built to learn some React Hooks with unit tests. The application is a simple page that fetches a movie list using HTTP request and display the result. This article will also cover unit tests using react testing library.

Demo on CodeSandbox:

Final code is on github:

GitHub logo fabiothiroki / react-hooks-movies

A cool simple interface for The Open Movie Database API

Thanks for Reiha Hosseini for the frontend design development.

Setup

As a prerequisite you must have Node >= 8.10 and npm >= 5.6 installed on your computer.

First we will create the application structure using create-react-app:

npx create-react-app react-hooks-movies
cd react-hooks-movies
npm start
Enter fullscreen mode Exit fullscreen mode

At this step, my project was built using React version 16.13.1.

Initial project structure

The creact-react-app creates a basic App component in the project root directory. We will move this file and its related to its own folder component to keep things more organized. I personally prefer to create a components folder and move all App components files to its own App component folder. Then you just need to change the App.js import path on index.js:

import App from './components/App/App';
Enter fullscreen mode Exit fullscreen mode

Project folder structure

Check your application on http://localhost:3000/ and everything should be working as before.

Optionally you can copy my index.css content so you have the same result as me.

Api

Now we can start writing the additional modules needed. We will start by writing the one responsible for making the http request to retrieve the movie data. We could build this part directly on App component but creating a separate module for this will help us write the tests in the near future.

The API used is the free OMDB. Be sure to signup there to get your own API Key, and place it as an environment variable called REACT_APP_API_KEY. This way you won't expose your key if you want to share your code on Github.

To get some fancy results and cool posters, we will use the search parameter to fetch movies that have 'war' on its name:

// src/api/api.js
const MOVIE_API_URL = `https://www.omdbapi.com/?apikey=${process.env.REACT_APP_API_KEY}`;

export const fetchMovies = (search = 'war') => (
  fetch(`${MOVIE_API_URL}&s=${search}`)
  .then(response => response.json())
);
Enter fullscreen mode Exit fullscreen mode

As you can see we're already returning a Promise containing a parsed JSON.

Movie component

This component will render each movie data returned in the array of the previous module. No secret here, just a plain React component:

// src/components/Movie/Movie.js
import React from "react";

const Movie = ({ movie }) => {
  return (
    <figure className="card">
      <img 
        src={movie.Poster}
        alt={`The movie titled: ${movie.Title}`}
      />
      <figcaption>{movie.Title}</figcaption>
    </figure> 
  );
};

export default Movie;
Enter fullscreen mode Exit fullscreen mode

Reducer

The reducer is a function that receives an action object, and a state object and returns the new state that will be rendered by App component. So basically we will use the reducer function to handle the application state, that will be managed by three variables: loading, movies and error.

// src/components/App/reducer.js
export const initialState = {
  loading: true,
  movies: [],
  errorMessage: null
};
Enter fullscreen mode Exit fullscreen mode

In this case I prefer the useReducer hook instead of the useState hook because I this state is complex enough.

The only action we need for now is the one dispatched when the API request returns successfully, we will call it SEARCH_MOVIES_SUCCESS.

export const reducer = (state, action) => {
  switch (action.type) {
    case "SEARCH_MOVIES_SUCCESS":
      return {
        loading: false,
        movies: action.payload,
        errorMessage: null,
      };
    default:
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

So whenever we receive this action, we update the current state to hide both the loading indicator and the error message and we update with API Response.

App component

Now on App component we just need to glue the api with its reducer and the Movie component.

UseReducer hook

The useReducer hook is a function that receives a reducer function like the one we have implemented on previous step and an object representing the initial state as second argument. Its return are two variables, the current state and a method for dispatching actions.

So first we add all new imports:

// src/components/App/App.js
import React, { useReducer } from 'react';
import { initialState, reducer } from "./reducer";
import Movie from "../Movie/Movie";
Enter fullscreen mode Exit fullscreen mode

Now we can call useReducer inside the functional component, get the initial state from it and render.

export const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const { movies, errorMessage, loading } = state;

  return (
    <div className="wrapper">
      <h2><strong>Movies</strong></h2>
      <div className="cards">

      {loading &&
        <span>loading...</span>
      }

      {errorMessage &&
        <span>{errorMessage}</span>
      }

      {movies &&
        movies.map((movie, index) => (
          <Movie key={`${index}-${movie.Title}`} movie={movie} />
        ))
      }

      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

If you run the current application on browser, you can notice the correct rendering of loading state.

UseEffect hook

Finally we will effectively render the cool movie posters. But what does this hook do?

By using this Hook, you tell React that your component needs to do something after render.

So in this case we will do the movies data fetching.

First, start adding the new imports:

import React, { useEffect, useReducer } from 'react';
import { fetchMovies } from '../../api/api'; 
Enter fullscreen mode Exit fullscreen mode

Then inside the component, just after defining the dispatch method you can already call the hook:

export const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    fetchMovies()
      .then(jsonResponse => {
        dispatch({
          type: "SEARCH_MOVIES_SUCCESS",
          payload: jsonResponse.Search
        });
      });
  }, []);

  // Hidden previous code
}
Enter fullscreen mode Exit fullscreen mode

The first parameter of useEffect is a function containing the effect itself and the second parameter is an array of values that the effect depends on, in case we want to conditionally fire this effect. In this case we can pass an empty array because this is a static page.

DOM Testing

At this step your application is working and hopefully is looking the same as the demo.

So now we will focus on testing with tools:

The first prerequisite for this test is the mock of the API part, because we're considering this is an external dependency and in this particular case we don't want to write automated tests. I've just copied the same json response of the API but kept only one movie to make the test simple and clean.

// src/components/App/App.test.js
import { fetchMovies } from '../../api/api'; 

jest.mock('../../api/api');

const mockReponse = {
  "Search": [{
    "Title": "Avengers: Infinity War", 
    "Year": "2018", 
    "imdbID": "tt4154756", 
    "Type": "movie", 
    "Poster": "https://m.media-amazon.com/images/M/MV5BMjMxNjY2MDU1OV5BMl5BanBnXkFtZTgwNzY1MTUwNTM@._V1_SX300.jpg" 
  }],
  "totalResults": "3964",
  "Response": "True"
};

beforeEach(() => {
  fetchMovies.mockResolvedValueOnce(Promise.resolve(mockReponse));
});
Enter fullscreen mode Exit fullscreen mode

By using Jest Mock feature, we're basically telling the tests to return the mockResponse whenever fetchMovies is called.

The test case we will focus on will consist by the following assertions in the following order:

  1. After App component is renderd, it should display the loading state.
  2. Then it should trigger the API request, and if successful, the loading state should be hidden.
  3. The movies should be rendered.
// src/components/App/App.test.js
import React from 'react';
import { render, waitForElementToBeRemoved } from '@testing-library/react';
import App from './App';

// mock configuration...

test('renders loading first, then movies', async () => {
  const { getByText } = render(<App />);

  // Should display loading state
  expect(getByText(/loading/i)).toBeTruthy();

  // Should trigger API request
  expect(fetchMovies).toHaveBeenCalledTimes(1);

  // Should hide loading state
  await waitForElementToBeRemoved(() => getByText(/loading/i));

  // Should display the movie returned on mock
  expect(getByText(/Avengers/i)).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

As an exercise for the reader, you could also write the test when the API returns an error.

Now if you run npm run test, this test should pass sucessfully!

Conclusion

I hope you could've learn the basics on how to consume an API and render its data using React Hooks! Automated tests should also be part of your application so I hope you enjoyed this part as well.

. . . . . . . . . . . . . . . . . . . . . . . . . . . .