This article is meant to help you understand three React hooks: useReducer, useEffect, and useContext, by using them in real-world scenarios. If you believe in learning by doing and want to learn more about these hooks, this article is what you're looking for.
For each one of these hooks, we'll answer the following questions:
- What is it?
- What's its purpose?
- How to implement it in our app?
Eventually, our app will look like this:
You can find the final source code here and the live version here.
The app requirements
- Users can search for a movie by title.
- Our app fetches the data from themoviedb API and displays the search results in the form of movie cards where each card has: the movie cover, the movie rating, and the number of votes.
- Users can select a movie from that list.
Project Setup
We use create-react-app CLI tool to bootstrap our project:
npx create-react-app moviefinder
This gives us the react boilerplate and takes care of the building process.
Clean our boilerplate
Let's delete the unnecessary files in our app.
From the command line, go to your src/ folder and execute the following commands:
cd src
rm App.test.js App.css logo.svg serviceWorker.js setupTests.js index.css
Create the folder that will contain our components files:
mkdir components
Create a javaScript file for each component:
cd components touch MoviesList MovieCard
Note: I am using "tailwind" to style the app without any CSS files, you can either copy our app tailwind configuration or feel free to style it on your own.
Now we are ready to code our app
Title
The First thing we can do is output the app title as a warm-up.
import React from "react";
function App() {
return (
<div className="w-1/2 h-screen sm:auto md:auto lg:auto shadow-2xl mx-auto flex flex-col items-center">
<div className="py-5">
<span className="text-5xl font-light text-white">Movie</span>
<span className="text-5xl font-light py-2 px-2 text-red-600 ">
Finder
</span>
</div>
</div>
);
}
export default App;
Input
Our second UI element would be an input field.
import React from "react";
function App() {
return (
<div className="w-1/2 h-screen sm:auto md:auto lg:auto shadow-2xl mx-auto flex flex-col items-center">
<div className="py-5">
<span className=" text-5xl font-light text-white">Movie</span>
<span className=" text-5xl font-light py-2 px-2 text-red-600 ">
Finder
</span>
</div>
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
/>
</div>
);
}
export default App;
As per the app requirement, users type-in a movie title.
Accordingly, we have to handle such a scenario in our app, so we need to implement two things:
- An state to store the typed-in value to use it in our app
- An event listener that calls a function to update that state every time an input is changed.
Let's start by our state.
Usually, we resort to useState hook to define our state. However, there is another advanced hook called useReducer.
What is useReducer?
Just like useState, useReducer is another hook to manage state. It has a different syntax and purpose, as explained in the following two parts.
Why useReducer and not useState to manage our state?
As we will see later, our state is a complex object with different primitive data. Sometimes these values are updated together and some of them depend on the previous values of others. To avoid having a useState function for each and multiple calls to their resulting setter functions every time we want to update something, we use useReducer to manage everything in one place and have better code readability.
How to implement useReducer in our app?
The useReducer hook takes a reducer and an initial state as arguments to return an array with two constant elements:
- A state.
- Something you can call it to update that state called dispatch.
Similarly to what we used to get from useState hook. An array that contains a state and a function to update that state.
const [state, dispatch] = useReducer(reducer, initialState);
Three steps to implement our useReducer hook:
1- Get access to useReducer in our app.
import React,{useReducer} from "react";
2- Call useReducer in our app function block.
import React from "react";
import { useReducer } from "react";
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div className="w-1/2 h-screen sm:auto md:auto lg:auto shadow-2xl mx-auto flex flex-col items-center">
<div className="py-5">
<span className="text-5xl font-light text-white">Movie</span>
<span className="text-5xl font-light py-2 px-2 text-red-600">
Finder
</span>
</div>
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
/>
</div>
);
}
export default App;
OOps!
We get an error in our console because we still did not define an initial state for our app and a reducer function.
3- Define the useReducer function arguments:
- Initial state: An object where all our application data lives in. Outside our App component, we define our first state value to store the typed-in movie title.
const initialState = {
typedInMovieTitle: "",
};
- Reducer: It is a function that takes the current sate and an action to return a new state.
(state, action) => newState
and our reducer function is the following:
const reducer = () => {};
Leave it empty for now just to clear our console from undefined argument error.
We have achieved the first step in setting up the logic to store what users type-in. We have where the typed data will be stored. Now we need to set up an event listener to track the changes as the users type-in.
We add an event listener as an attribute to our input element. It references a javaScript function called onChange
.
function onChange() {}
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
onChange={onChange}
/>
How to use the onChange, dispatch, and reducer to update our state?
Inside our onChange
function block we call the dispatch
method we got from calling the useReducer function and we pass to it an object of two properties as a dispatch command to our reducer function.
const ACTIONS = {
TYPE_SEARCH: "TYPE_SEARCH",
};
function onChange(event) {
dispatch({
type: ACTION.TYPE_SEARCH,
value: event.target.value,
});
}
The concept of dispatch is to just declare what changes we want to make to our state when an action happens. For example here, when the action type
is ACTION.TYPE_SEARCH
, which means a user typed something on the input, we want the typed value value: event.target.value
to be assigned to typedInMovieTitle
. The reducer is the one that will host the logic to this implementation.
Let's take a look at the passed object properties:
-
Type: Type of action that happened. We define a proper
ACTIONS
object that holds all the action types. - Value: The typed-in movie title by the user.
Now we are ready to use the reducer function that we left empty.
The object we passed by the dispatch method mimics a scenario that happens on our app. In our case, It can either be a scenario of a user interacting with the app or a data fetching operation. It is passed as an action
parameter to our reducer.
const reducer = (state, action) => {
switch (action.type) {
case ACTION.TYPE_SEARCH:
return {
...state,
typedInMovieTitle: action.value,
};
default:
return state;
}
};
Once the reducer receives the action object, we use a switch statement to determine the scenario that happens on our app using the action.type
property. For every scenario case, our function should return an object that contains the unchanged state values and the updated ones. Accordingly, typedInMovieTitle
is assigned the value
we passed in our dispatched object, which is the typed value by the user. In a case where there is no action on our app, a default case, it returns the state.
Nice!
We just finished setting up our code logic to track what users type-in and where to store it. Now we have to allow a user to submit this data to use it later for our fetch operation.
Submit form
Once the user finishes typing the movie name, he submitted it by clicking "Enter".
To make this requirement possible we go through the following implementation steps:
- Wrap our input element in a form and handle the form submission with a javaScript function called onSubmit.
function onSubmit() {}
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
onChange={onChange}
/>
</form>
- Define an action for the movie submission scenario.
const ACTIONS = {
...
SUBMIT_SEARCH: "SUBMIT_SEARCH"
};
- Define a new state value
submittedMovieTitle
to store this data.
const initialState = {
...
submittedMovieTitle: ""
};
- Inside the
onSubmit
function we dispatch a command to our reducer to update thesubmittedMovieTitle
state value.
function onSubmit(event) {
event.preventDefault();
dispatch({
type: ACTION.SUBMIT_SEARCH,
});
}
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.SUBMIT_SEARCH:
return {
...state,
submittedMovieTitle: state.typedInMovieTitle,
};
default:
return state;
}
};
If you notice here, in our dispatch method we did not include a value
attribute. It is because our reducer is updating the submittedMovieTitle
with the previous value we got from typedInMovieTitle
.
Fetch the movies data
Now we use the submittedMovieTitle
as a query argument to fetch the movies data from the themoviedb.
Another argument is used in the query link is the API key:
const API_Key = "16c66b0f7fd3c3447e7067ff07db3197";
Note: Read the database documentation to know how to generate an API key for your app.
And this is our data fetching link.
`https://api.themoviedb.org/3/search/movie?api_key=${API_Key}&query=${state.submittedMovieTitle}`
This operation will lead us to our second important hook which is useEffect.
What is useEffect and Why do we use it?
It is a hook to perform side effects, i.e. operations that affect anything outside our component such as API calls, DOM manipulation, and user authentication.
The useEffect hook takes two arguments:
- A callback function that executes the code after rendering the components.
- A Dependency array.
useEffect(()=>{},[])
Dependency array
This array simply controls when useEffect runs. It tells react to run the code inside the useEffect only when the values inside the array change. All values from inside the component that are used in the useEffect function must be in the array, including props, state, functions. Sometimes this might cause an infinite loop but there is another solution to such issue rather than hiding these variables from the dependency array.
To make sure that our dependency array has all the required values, we install the ESLint plugin 'exhaustive-deps'.
Installation
yarn add eslint-plugin-react-hooks@next
# or
npm install eslint-plugin-react-hooks@next
ESLint config:
{ "plugins": ["react-hooks"],
"rules": { "react-hooks/rules-of-hooks": 'error',
"react-hooks/exhaustive-deps": 'warn'
}
How do we implement useEffect:
Similarly to useReducer we implement our useEffect hook:
- Get access to useEffect in our app
import React,{useEffect,useReducer} from "react";
- Define the proper actions that come with the fetch operation.
const ACTIONS = {
...
FETCH_DATA: "FETCH_DATA",
FETCH_DATA_SUCCESS: "FETCH_DATA_SUCCESS",
FETCH_DATA_FAIL: "FETCH_DATA_FAIL",
};
- Define new state variables to store the fetched
movies
and the loading state of data.
const initialState = {
typedInMovieTitle: "",
submittedMovieTitle: "",
movies: [],
isLoading: false,
isError: false,
};
- Implement our fetch operation logic. We use a conditional statement to check if a user submitted a movie title. If so, we fetch data from the API and dispatch the proper commands for our reducer function to update the corresponding state values.
const API_Key = "16c66b0f7fd3c3447e7067ff07db3197";
useEffect(() => {
if (state.submittedMovieTitle) {
const fetchData = async () => {
dispatch({ type: "FETCH_DATA" });
try {
const result = await axios(
`https://api.themoviedb.org/3/search/movie?api_key=${API_Key}&query=${state.submittedMovieTitle}`
);
dispatch({
type: ACTION.FETCH_DATA_SUCCESS,
value: result.data.results,
});
} catch (error) {
dispatch({ type: "FETCH_FAILURE" });
}
};
fetchData();
}
}, [state.submittedMovieTitle]);
We have three scenarios:
- Data fetching is ongoing. Reducer returns an object that contains the unchanged
state
values and updated value ofisLoading
which is true.
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.FETCH_DATA:
return {
...state,
isLoading: true,
};
default:
return state;
}
};
- Data fetching succeed. Reducer function returns an object that includes the unmodified
state
values and updated values ofmovies
andisLoading
. Themovies
state value is assigned the fetched list of movies. TheisLoading
is false because the fetched successfully happened and we are not waiting for our data anymore.
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.FETCH_DATA_SUCCESS:
return {
...state,
movies: action.value,
isLoading: false,
};
default:
return state;
}
};
- Data fetching failed. Similarly, our reducer returns an object with only one updated state value,
isError
to true.
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.FETCH_DATA_FAIL:
return {
...state,
isError: true,
};
default:
return state;
}
};
Now we want to show something to the user whenever a state is updated in every scenario we mentioned above.
In case data is loading, we want to output a circular progress component on our DOM to inform users that data is loading. If data failed to load, we just output a text mentioning data failed to load.
To do so:
- We get access to a
CircularProgress
component from the Material-UI library.
import CircularProgress from "@material-ui/core/CircularProgress";
Use a ternary operator to conditionally output:
- The
CircularProgress
ifisLoading
value is true.
- The text "Data failed to load" if
isError
is true.
- The fetched movies if all of the above are false.
{state.isLoading ? (
<CircularProgress color="secondary" />
) : state.isError ? (
<p className="text-white shadow-xl mt-10 font-bold">
Data failed to load
</p>
) : (
<MoviesList movies={movies} />
)}
Do not worry about the MoviesList
component code, we will discuss it in detail in the MoviesList component section.
Now Let's dive in a little bit in how our usEffect function runs, what actually happens under the hood?
Before a user types-in any movie name
1- React renders the component based on the initial state.
2- After the first render, the useEffect function runs but it doesn't do anything since state.submittedMovieTitle
is still empty.
When a user submits a movie name:
1- When the user submits a movie title, state.submittedMovieTitle
is updated, which triggers a new render.
2- After that render, React will realize that the new state.submittedMovieTitle
value is different from the last iteration and runs the effect function.
3- Since state.submittedMovieTitle
is now defined, an API request is made to fetch the movies.
To output the fetched movies we do the following:
- Create a MoviesList component in our MoviesList.js file.
MoviesList component
Productivity tip: Download the VS Code ES7 React/Redux/React-Native/JS snippets extension. It saves you time by automatically creating a functional component that has the name of its file and a default export.
In the MoviesList.js file type the following prefix:
rcfe
And you will get your component.
import React from 'react'
function MoviesList() {
return (
<div>
</div>
)
}
export default MoviesList
- Import MoviesList in our App.js file to be able to call it in our App component.
import MoviesList from "./MoviesList";
function App() {
...
return (
<div className="w-1/2 sm:auto md:auto lg:auto shadow-2xl h-screen mx-auto flex flex-col items-center">
<div className="py-5">
<span className="text-5xl font-light text-white ">Movie</span>
<span className="text-5xl font-light py-2 px-2 text-red-600 ">
Finder
</span>
</div>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Search"
className=" rounded shadow-2xl outline-none py-2 px-2"
onChange={onChange}
/>
</form>
<MoviesList />
</div>
);
}
export default App;
- Pass to it the fetched movies as a prop.
<MoviesList movies={movies} />
- To make our code more readable. Let's make our MoviesList component responsible for only one task. Inside the component, we map through the passed
movies
array and pass every movie as a prop to the MovieCard component.
function MoviesList({ movies }) {
return (
<div className="overflow-auto my-3">
{movies.map((movie, index) => (
<MovieCard key={movie.index} movie={movie} />
))}
</div>
);
}
export default MoviesList;
We get an error in our console because we still did not define our MovieCard component.
- Define a MovieCard component to output the movie details in a card.
MovieCard component
Similarly to the way we created the MoviesList component we use the ES7 React snippet to implement the MovieCard component.
In the MovieCard.js file type:
rcfe
And we get our component.
import React from 'react'
function MovieCard() {
return (
<div>
</div>
)
}
export default MovieCard
The MovieCard gets a movie from the MoviesList to render its cover, rating, and the number of votes in a card.
function MovieCard({ movie }) {
return movie.poster_path ? (
<div className="max-w-sm overflow-hidden shadow-xl mt-3 mb-6 rounded-lg shadow-2xl">
<img
src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`}
alt="404"
className=" w-full object-cover "
/>
<div className="py-2 bg-white text-black font-semibold flex justify-evenly items-center">
<div className=" flex flex-col justify-center items-center ">
<span className="" role="img" aria-label="Star">
⭐️
</span>
<p>{movie.vote_average}</p>
</div>
<span className=" flex flex-col justify-center items-center ">
<p className="sm:text-xs">Vote </p>
<p className="sm:text-xs">{movie.vote_count} </p>
</span>
</div>
</div>
) : null;
}
export default MovieCard;
Why are we using conditional rendering in our MovieCard component?
If you take a look at the movies we get from our API, some of them do not have a cover and we are not interested in ruining our UI with a bunch of not found alert that occupies the cover position.
To make our app looks good we just render the movies that have a cover.
Now we get to the Last requirement in our app.
Selecting a movie
For the user to select a movie he can tick a checkbox on the movie card and eventually our UI will display only that movie card. To put it as a code logic, we have to update our state movies
that stores the movies list to have only the selected movie. In this way, React will re-render our UI to show only that selected movie.
Before we jump into coding our logic, let's implement a checkbox in the MovieCard.
- Get access to a Checkbox component from the Material-UI library.
import Checkbox from "@material-ui/core/Checkbox"
- Integrate it into our MovieCard.
function MovieCard({ movie }) {
...
return movie.poster_path ? (
<div className="max-w-sm overflow-hidden mt-3 mb-6 rounded-lg shadow-2xl">
<img
src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`}
alt="404"
className=" w-full object-cover "
/>
<div className="py-2 bg-white text-black font-semibold flex justify-evenly items-center">
<div className=" flex flex-col justify-center items-center ">
<span className="sm:text-xs" role="img" aria-label="Star">
⭐️
</span>
<p className="sm:text-xs">{movie.vote_average}</p>
</div>
<span className=" flex flex-col justify-center items-center ">
<p className="sm:text-xs">Vote </p>
<p className="sm:text-xs">{movie.vote_count} </p>
</span>
<Checkbox color="default"/>
</div>
</div>
) : null;
}
export default MovieCard;
- Add an onChange event listener as an attribute to our Checkbox component.
<Checkbox color="default" onChange={onChange} />
Once a user checks a movie card, our onChange
event listener triggers an onChange
event handler.
function onChange(event) {}
Let's also have a selectMovie
function in our App component where we have access to the dispatch method.
function selectMovie() {}
- Inside it, we call the dispatch method and pass to it our typical object that mimics the movie selection scenario. We just want to tell the reducer if there is any movie selection action update our
movies
state to contain only that selected movie.
const ACTIONS = {
...
SELECT_MOVIE: "SELECT_MOVIE",
};
function selectMovie(movie) {
dispatch({
type: ACTION.SELECT_MOVIE,
value: [movie],
});
}
As you notice, we are missing the movie
parameter. It is the selected movie object that we want to pass to our reducer as a value to update our state as we mentioned before.
Accordingly, inside our onChange
function in the MovieCard, we call the selectMovie
function and pass to it the movie
as an argument. In this way, it can use it as a parameter to dispatch the command.
To call the selectMovie
function inside our MovieCard we have to pass it as a prop from our root component to the MoviesList component then again pass it as a prop to the MovieCard component.
<MoviesList movies={state.movies} selectMovie={selectMovie}/>
function MoviesList({ movies,selectMovie }) {
return (
<div className="overflow-auto my-3">
{movies.map((movie, index) => (
<MovieCard key={movie.index} movie={movie} selectMovie={selectMovie}/>
))}
</div>
);
}
Call the selectMovie
function in our handler.
function MovieCard({ movie,selectMovie }) {
function onChange(event) {
selectMovie(event.target.checked ? movie : null);
}
...
}
export default MovieCard;
If a movie card checkbox is checked, our onChange
calls the selectMovie
function with the selected movie
as an argument. Else, a null
takes a place as an argument. Once our reducer receives the right parameters, it embarks on changing the state accordingly.
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.SELECT_MOVIE: {
return {
...state,
movies: action.value,
};
}
default:
return state;
}
};
Once the state is updated React renders the changes to the DOM:
Perfect!
However, there is an issue with this logic though. Our user in this case can not go back to the list of movies to select a different movie if he changes his mind. This because we changed our state movies
value to contain only the selected movie. We can't fetch the list of movies again and assign it to movies
because the fetch happens only once.
How can we solve it ?
We have to keep the state movies
unchanged. In other words, it will always store the list of movies and not assign it another value.
To do so:
- First, we define
selectedMovie
as the last state variable that is responsible for storing only the selected movie object.
const initialState = {
...
selectedMovie: {},
};
- Then change our reducer code.
function selectMovie(movie) {
dispatch({
type: ACTION.SELECT_MOVIE,
value: movie,
});
}
const reducer = (state, action) => {
switch (action.type) {
...
case ACTION.SELECT_MOVIE: {
return {
...state,
selectedMovie: action.value,
};
}
default:
return state;
}
};
Now as a better solution, we have two values to render conditionally, a selected movie or the movies list if there is no selected one.
- Define a const variable call it
filteredMovies
and give it a value based on the two scenarios we mentioned earlier:
1- If a user selects a movie then we assign it an array that contains only that movie object.
2- Else we give it the movies list.
const filteredMovies = !state.selectedMovie
? state.movies
: [state.selectedMovie];
Then we pass the filteredMovies value as a prop to the MoviesList component.
<MoviesList filteredMovies={filteredMovies} />
Let's test it.
The app works fine and we managed to code all the functionalities. However, there is one last thing to fix in our code, we have to use a better coding practice when passing our selectMovie
function from the root component to the MovieCard component.
We should use something that enables us to directly pass the selectMovie
function to our MovieCard component without drilling it down the components tree. The MoviesList does not need the selectMovie
function, so why bother using it as an intermediate.
And here we get to introduce our last hook feature, useContext.
What is useContext?
Let us first remember what is Context API in React. It is an alternative to "pop-drilling", so instead of passing data through the component tree, it allows us to go back, have a global state that can be consumed by only the components interested in it.
Why do we need useContext in our app?
If we draw a simple scheme of our App components
We can see that we are passing the selectMovie that updates the local state two layers down. Having a global state will allow us to skip the MovieList component layer and directly pass the function to the MovieCard. It is also useful to know such a technique for your future practices too. You might have deeply nested components that all of them or some of them might need access to a Theme, language preference, or authentication information.
How do we use useContext?
Let's have a separate file for our context creation, call it moviesContext.js.
Three steps to implement useContext:
- Access
createContext
in our file which allows us to create our context object.
import { createContext } from "react";
- Create our
moviesContext
object.
const moviesContext = createContext(null);
export default moviesContext;
- Get access to our
moviesContext
in our App component.
import moviesContext from "../moviesContext";
- Wrap the children components of our App component with a Provider that we get from
moviesContext
object.
<moviesContext.Provider>
<div className=" app w-1/2 h-screen sm:auto md:auto lg:auto shadow-2xl h-screen mx-auto flex flex-col items-center">
<div className="py-5">
<span className=" text-5xl font-light text-white ">Movie</span>
<span className=" text-5xl font-light text-white py-2 px-2 text-red-600 ">
Finder
</span>
</div>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
onChange={onChange}
/>
</form>
<MoviesList filteredMovies={filteredMovies} />
</div>
</moviesContext.Provider>
The Provider component allows our children components to subscribe to the moviesContext
and consume it. It takes a value
prop that is passed to all the consuming components. In our case, it would be the selectMovie
function, because we want our MovieCard to be able to access it.
<moviesContext.Provider value={selectMovie}>
<div className=" app w-1/2 h-screen sm:auto md:auto lg:auto shadow-2xl h-screen mx-auto flex flex-col items-center">
<div className="py-5">
<span className=" text-5xl font-light text-white ">Movie</span>
<span className=" text-5xl font-light text-white py-2 px-2 text-red-600 ">
Finder
</span>
</div>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Search"
className="rounded shadow-2xl outline-none py-2 px-2"
onChange={onChange}
/>
</form>
<MoviesList filteredMovies={filteredMovies} />
</div>
</moviesContext.Provider>
- Get access to
useContext
in our MovieCard component so we can consume ourmoviesContext
there.
import React, { useContext } from "react";
- Consume the context in our movieCard
function MovieCard({ movie }) {
const selectMovie = useContext(moviesContext);
function onChange(event) {
selectMovie(event.target.checked ? movie : null);
}
...
}
export default MovieCard;
}
Done.
Validate our code
One last thing we can add to our application is props validation using prop-types to avoid catching bugs. Our passed in props have to be of certain types. Let's implement this in our code.
- First, we get access to
PropTypes
from'prop-types'
in our App and MoviesList components.
import PropTypes from "prop-types";
- Use the validators we get from PropTypes to enforce validation rules on the passed props.
App component
MoviesList.propTypes = {
filteredMovies: PropTypes.arrayOf(PropTypes.object),
};
MoviesList component
MovieCard.propTypes = {
movie: PropTypes.object,
};
Conclusion
I publish articles monthly and I'm currently looking for my first Frontend Developer job in either Europe or Canada.
Stay tuned by following me on Twitter (@amir_ghezala) or checking my portfolio.
Top comments (0)