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 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.
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).
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.
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:
Our Load More
button can be clicked on to load more items for the same search:
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 [email protected] 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!