loading...
Cover image for Building a Movie App with React Hooks and Cloudflare Workers

Building a Movie App with React Hooks and Cloudflare Workers

jgb profile image Jean Gérard Bousiquot Updated on ・11 min read

Hey there! This is my first tutorial here. My name is Jean Gérard and I'm a developer based out Port-au-Prince, Haiti.

So I've been working on this Spotify/SoundCloud clone app called MP3 Pam for a couple of months now. React on the front-end and Laravel on the back-end for the API. I use React Hooks and Cloudflare Workers quite a bit and I thought it would be good to share some of the things I've learned on the internet. ;)

So what are we going to build? A movie app (movie-app-workers.jgb.solutions) that allows you to search for any movies, series or TV shows. We'll make use of the OMDb API. It's free for up to 1000 requests per day. We will use Cloudflare Workers to protect our API key, do some rerouting, and a lot of caching. That will allows us to bypass their 1000 requests per day limit and get nice API urls for free, since Cloudflare Workers is free for up to 100 000 requests per day.

So what is React? React is a JavaScript library (can also be called a framework) that allows you to create better UI (user interface) for web (React.js) and mobile (React Native).

What about this React Hooks thing? Yeah, so according to the official docs Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class. Hooks are backwards-compatible. This page provides an overview of Hooks for experienced React users. This is a fast-paced overview. In other words, Hooks will allow us to use just functional components and add state management and lifecycle to them without the need to use class. And that's a good thing because they seem to offer many advantages over traditional React class component.

And about that last buzzword in our list, Cloudflare Workers? Their docs state that Cloudflare Workers provides a lightweight JavaScript execution environment that allows developers to augment existing applications or create entirely new ones without configuring or maintaining infrastructure. In other words, we can use it to do what traditional servers do, only we won't need to manage or even pay for them. Yay!

Okay now to complete this tutorial you need some React knowledge, Node.js, a code editor, and a browser installed on your machine.

To follow along you can clone the starter files (client, api) and git checkout starter to access the starter branch or use create-react-app and wrangler to create a new react project and a workers project respectively.

cd into the client folder and run yarn. That command will install the node dependencies needed to run our app while developing locally. While you're at it pull lodash-es with yarh add lodash-es. We will make use of its get method to access object properties without getting errors when the object or any of the property's parent properties are undefined.

I already imported the Bootstrap 4 CSS in the App.css file to get us started with some basic styling since that's not the main topic of the tutorial.

Once everything is installed run yarn start and you should see a blank page. That's right. We haven't done anything fancy yet.

Now We need to create 2 files in the src folder: MovieList.js and useMovies.js.

MovieList.js will be responsible for displaying the search input and the the list of movies (series, tv shows) and also load more items from the API.

Go ahead a paste this bit of code in it and I will explain what it does.

import React from 'react';
import { get } from 'lodash';

import useMovies from './useMovies';
import logo from './logo.svg';

let debounceSearch;

function MovieList() {
  const [
    movies,
    setSearchTerm,
    isLoading,
    canLoadMore,
    fetchMovies,
    lastSearchTerm,
    setMovies,
  ] = useMovies()

  const handleSearch = event => {
    const searchTerm = event.target.value.trim();

    if (searchTerm.length > 2) {
      clearTimeout(debounceSearch)
      // do search
      debounceSearch = setTimeout(() => {
        setSearchTerm(searchTerm);
      }, 500);
    } else {
      setMovies([]);
    }
  }

  return (
      <div className="col-sm-8 offset-sm-2">
        <header>
          <h1>
            <img src={logo} alt='Movie App Workers' className='logo' f/>
            Movie App
          </h1>
        </header>
        <form>
          <div className="input-group">
            <input type="text"
              className="form-control"
              placeholder="Search any movie, series or TV Shows"
              onChange={handleSearch}
            />
          </div>
        </form>
        <br />
        {isLoading && <h2>Search Loading...</h2>}
        <div className="row">
          {movies.length ? (
            movies.map(movie => {
              const title = get(movie, 'Title', `No Title`);
              const movieId = get(movie, 'imdbID')
              let poster = get(movie, 'Poster');
              if (!poster || poster === 'N/A') {
                poster = `https://dummyimage.com/300x448/2c96c7/ffffff.png&text=No+Image`;
              }
              const type = get(movie, 'Type', `undefined`);
              const year = get(movie, 'Year', `undefined`);

              return (
                <div key={movieId} className="col-sm-6 mb-3">
                  <div className="row">
                    <div className="col-7">
                      <img src={poster} alt={title} className='img-fluid' />
                    </div>
                    <div className="col-5">
                      <h3 className='movie-title'>{title}</h3>
                      <p>Type: {type}.<br /> Year: {year}</p>
                    </div>
                  </div>
                </div>
              )
            })
          ) : lastSearchTerm.length > 2 ? <div className="col-12"><h2>No Movies Found</h2></div> : null}
        </div>
        {!!movies.length && canLoadMore && (
          <button
            className='btn btn-primary btn-large btn-block'
            onClick={fetchMovies}>
            Load More
          </button>
        )}
        <br />
        <br />
        <br />
      </div>
    )
}

export default MovieList;

This is a huge piece of code, I will admit it. So what is happening here is that we begin by creating regular functional component.

import React from 'react';
import { get } from 'lodash';

import useMovies from './useMovies';
import logo from './logo.svg';

We import react, the get method from lodash, the useMovies hook (that we will fill in a second) and the default react logo that we use next to the title of the app.

Next we have

let debounceSearch;

this variable will hold a timer id that we use to delay the call to the API by not calling an API for every key stroke but rather wait for half a second (500 milliseconds) to hit it.

The next interesting bit is:

 const [
    movies,
    setSearchTerm,
    isLoading,
    canLoadMore,
    fetchMovies,
    lastSearchTerm,
    setMovies,
  ] = useMovies()

Here we call our useMovies hook that gives us a list of movies, a setSearchTerm method to set the value for which we want to search, canLoadMore is a boolean that tells us whether we can load more movies or not and thus we will show or hide the load more button, fetchMovies is the method we will call when we want new movies, lastSearchTerm is a string that stores that last value that we successfully had a result for and thus let us compare it to the current string value we want to search for to see whether we want to make a new search and clear the list we have or append to it, setMovies allows to empty the list of movies when the length of the characters is less than 3.

Next we have:

const handleSearch = event => {
    const searchTerm = event.target.value.trim();

    if (searchTerm.length > 2) {
      clearTimeout(debounceSearch)
      // do search
      debounceSearch = setTimeout(() => {
        setSearchTerm(searchTerm);
      }, 500);
    } else {
      setMovies([]);
    }
  }

Here we use the input change event to access that value of the text, trim it for white spaces, use the setTimeOut function to delay the call for half a second, otherwise we set the list to an empty array.

Now:

const title = get(movie, 'Title', `No Title`);
const movieId = get(movie, 'imdbID')
let poster = get(movie, 'Poster');
if (!poster || poster === 'N/A') {
 poster = `https://dummyimage.com/300x448/2c96c7/ffffff.png&text=No+Image`;
}
const type = get(movie, 'Type', `undefined`);
const year = get(movie, 'Year', `undefined`);

We use get from lodash to avoid errors with undefined objects and properties, provide default values for texts and the poster and we store those values in new variables that we use in our JSX returned by the function.

{!!movies.length && canLoadMore && (
  <button
   className='btn btn-primary btn-large btn-block'
   onClick={fetchMovies}>
   Load More
  </button>
)}

In this bit of code, first we cast the movies.length value to a boolean, and if that's true and if we can load more we display the load more button that itself calls the fetchMovies method.

And that is a quick tour of the code. I'm hoping you can understand the rest. Otherwise hit me on Twitter here.

Now paste this code in your useMovies.js file:

import { useState, useEffect } from 'react';

function useMovies() {
  const [movies, setMovies] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [canLoadMore, setCanLoadMore] = useState(false);
  const [page, setPage] = useState(1)
  const [searchTerm, setSearchTerm] = useState(undefined)
  const [lastSearchTerm, setLastSearchTerm] = useState('')

  const fetchMovies = async () => {
    setIsLoading(true);
    if (searchTerm !== lastSearchTerm) {
      setPage(1);
      setMovies([]);
    }

    try {
      const response = await fetch(
        `https://movie-api-app.jgb.solutions/search/${searchTerm}?page=${page}`
      );
      const responseBody = await response.json();
      const movies = responseBody.Search;
      const totalResults = parseInt(responseBody.totalResults);
      setIsLoading(false);

      if (searchTerm === lastSearchTerm) {
        setMovies(prevMovies => [...prevMovies, ...movies]);
      } else {
        setMovies([...movies]);
        setLastSearchTerm(searchTerm);
      }

      if (totalResults - (page * 10) > 0) {
        setCanLoadMore(true);
        setPage(prevPage => prevPage + 1)
      } else {
        setCanLoadMore(false);
        setPage(1)
      }

      console.log('response', responseBody);
    } catch (error) {
      console.log(error);
      setIsLoading(false);
    }
  };

  useEffect(() => {
    if (searchTerm)
      fetchMovies();
  }, [searchTerm]);

  return [
    movies,
    setSearchTerm,
    isLoading,
    canLoadMore,
    fetchMovies,
    lastSearchTerm,
    setMovies,
  ];
}

export default useMovies;

Let's go over the code piece by piece.

import { useState, useEffect } from 'react';

We begin by importing useState and useEffect from react. React doesn't need to be imported if we won't use any JSX in our hook. And yes you can return JSX in your hooks if you wish because they are React components.

const [movies, setMovies] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [canLoadMore, setCanLoadMore] = useState(false);
  const [page, setPage] = useState(1)
  const [searchTerm, setSearchTerm] = useState(undefined)
  const [lastSearchTerm, setLastSearchTerm] = useState('')

Next inside the function we initialize some states that I won't go over again, because I've already discussed their use above.

const fetchMovies = async () => {
    setIsLoading(true);
    if (searchTerm !== lastSearchTerm) {
      setPage(1);
      setMovies([]);
    }

    try {
      const response = await fetch(
        `https://movie-api-app.jgb.solutions/search/${searchTerm}?page=${page}`
      );
      const responseBody = await response.json();
      const movies = responseBody.Search;
      const totalResults = parseInt(responseBody.totalResults);
      setIsLoading(false);

      if (searchTerm === lastSearchTerm) {
        setMovies(prevMovies => [...prevMovies, ...movies]);
      } else {
        setMovies([...movies]);
        setLastSearchTerm(searchTerm);
      }

      if (totalResults - (page * 10) > 0) {
        setCanLoadMore(true);
        setPage(prevPage => prevPage + 1)
      } else {
        setCanLoadMore(false);
        setPage(1)
      }

      console.log('response', responseBody);
    } catch (error) {
      console.log(error);
      setIsLoading(false);
    }
  };

The fetchMovies is an async method (because we want to use async/await) that sets the loading state, set the pagination depending on whether we are searching for a new movie (series, tv show), that way we can fetch new stuff when needed. Next we use Fetch to hit our API endpoint, extract the movies and totalResults from the response, set the loading state, appending the movies in our movies array or set the array to the movies, and update the lastSearchTerm. Then we check to see if we have more items to load for this term by subtracting the product of the number of pages we are in by 10, because 10 is the number of items we have per response.

Now we need to update the App.js file to import the MovieList component like so:

import React from 'react';

import MovieList from './MovieList';

import './App.css';

function App() {
  return (
    <div className="container">
      <div className="row">
          <MovieList />
      </div>
    </div>
  );
}

export default App;

And with that our app should be able to display results for any query like so:
Alt Text

Our Load More button can be clicked on to load more items for the same search:
Alt Text

Note that we are making use of the API that I have setup so you need to setup your own for your app.

Cloudflare Workers is built on top of the Service Worker API which is a somewhat new standard in browsers that allows you to do fancy stuff such as caching of assets, push notifications and more. It's a key feature that Progressive Web App makes use of. Cloudflare Workers uses the same V8 engine that Node.js and Google Chrome run on.

Now to the Cloudflare Workers API.
Use the API starter branch to have a head start.
Open the project in your code editor. We need to edit 2 files: wrangler.toml and index.js.
Head over to Cloudflare, creat an account if you haven't already and start adding a domain if have any. But one is not required to start using Cloudflare Workers. The account id and the zone id are required if you want to publish your worker to your own domain. You can create your own wokers.dev subdomain here. You will also need your API key and your email. Once you have those last two, run wrangler config to configure your account with the CLI tool. You can also use environment variables every time you are publishing a worker like so:

CF_API_KEY=superlongapikey CF_EMAIL=testuser@example.com wrangler publish

Now open your index.js file and paste this bit of code:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event))
})

const API_KEY = `yourApiKey`
const API_URL = `http://www.omdbapi.com`

// if you want to fetch a single movie.
// const getSingleMovieUrl = movieId =>
//   `http://www.omdbapi.com/?i=${movieId}&apiKey=${API_KEY}`

const getSearchUrl = (searchTerm, page = 1) =>
  `http://www.omdbapi.com/?s=${searchTerm}&page=${page}&apiKey=${API_KEY}`

async function fetchApi(event) {
  const url = new URL(event.request.url)
  const uri = url.pathname.split('/')
  const page = url.searchParams.get('page')
  let urlToFetch = `https://movie-app-workers.jgb.solutions/`

  // if you want to fetch a single movie.
  // if (uri[1] === `movie`) urlToFetch = getSingleMovieUrl(uri[2])
  if (uri[1] === `search`) urlToFetch = getSearchUrl(uri[2], page)

  const cache = caches.default
  let response = await cache.match(event.request)

  if (!response) {
    response = await fetch(urlToFetch, { cf: { cacheEverything: true } })
    // const headers = { 'cache-control': 'public, max-age=31536000' }
    // response = new Response(response.body, { ...response, headers })
    event.waitUntil(cache.put(event.request, response.clone()))
  }
  return response
}

async function handleRequest(event) {
  if (event.request.method === 'GET') {
    let response = await fetchApi(event)
    if (response.status > 399) {
      response = new Response(response.statusText, { status: response.status })
    }
    return response
  } else {
    return new Response('Method not allowed', { status: 405 })
  }
}

We start by listening to the fetch event and then respond with a method that handle the request.
We set our API key that we get from http://www.omdbapi.com/apikey.aspx, and the API url.

We then check to see whether the method of the request is GET otherwise we will just deny access. If they are requesting using GET then we use our helper function fetchApi that uses the event param to extract the path, the search term and the page query string. Once we have the new url we check in our cache whether we have a match. If we don't we fetch the url from the OMDb API and store the response in a response variable. What's interesting here is the second parameter where we pass { cf: { cacheEverything: true } } to fetch, this is one way to tell Cloudflare to catch the response for as long as possible in its large network of data centers (they even have one in Port-au-Prince. Yay!). And then we return the response.

Now to test live we can run wrangler preview and it will build and publish our worker on Cloudflare and open a new browser tab for us to try our worker. And with that we are done with our worker function. I would advice using a tool such as Postman to test the API responses. One thing to pay attention to is the response header of the API. If Cloudflare cached the response it will send a header called cf-cache-status with a value of HIT, otherwise it will be equal to MISS. If you hit the API with the same term it should return HIT on the second request. If not you have done something wrong.

Don't forget to update your API url in the React app to use your own API key. :)

And with all that you have a very fast app that uses React, Hooks and Cloudflare Workers.

I hope that even if this tutorial was a bit long, you have learn a thing or two in it.

Do you have any suggestions or know or have built some more cool stuff with either of those technologies, just let me know in the comments. Thanks!

Update

Hey there! If you need to host your websites or apps and you are on a budget then Vultr is a great place to start. You can try it for free and receive $100 in credits. I'll also receive $25 in credits if you do. Click here to get your credits. Vultr is fast, reliable and cheap. Get your $100 credits here

Posted on by:

jgb profile

Jean Gérard Bousiquot

@jgb

Software Engineer by day. Entrepreneur by night. Web & Mobile. const { #JavaScript, #React, #React_Native, #Laravel, ...otherCoolStuff } = JGB

Discussion

markdown guide
 

I will give service worker a try. Thanks for the sharing

 

You are welcome Emmanuel. Thanks for reading!