Movie Mania
May 3, 2019
Have you visited IMDB lately? There's a ton of great content, but it can be overwhelming at times, especially if you are simply looking to check out the details of a movie. With a goal of creating a site that provides easier access to movie information, alongside a desire to sharpen my Redux knowledge, I decided to build Movie Mania.
App architecture and Redux setup
Movie Mania has a directory structure of:
/src
/actions
index.js
/apis
moviedb.js
/components
App.js
...
/reducers
index.js
...
/selectors
index.js
index.js
helper.js
Since we're using Redux, separate directories are created to contain our actions, components, and reducers used in the app. Selectors to compute derived data from the Redux store are contained in the selectors directory. Various helper functions (e.g. formatting a film's revenue value) are contained in helper.js
.
In the root index.js
file, the Redux store is created and make it accessible to the App
component and its child components via the Provider
component from react-redux, the library that binds Redux to React components. Since we'll be making asynchronous API calls, redux-thunk is used as middleware.
Three packages need to be installed to setup the store:
npm install --save redux react-redux redux-thunk
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import App from './components/App';
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.querySelector('#root')
);
The rootReducer
used to create the Redux store is exported from /reducers/index.js and contains the reducing function for the state tree of this application. More on that later in this post.
Data source, fetching, and processing
Movie DB API
Movie Mania is made possible through the amazing Movie Database API, a public API with loads of information on past and current movies. The API is free to use after registering for an account and requesting an API key.
Five endpoints are used in Movie Mania:
- Discover - to fetch movies based on their popularity, release date, ratings, genre, actors, and more
- Trending - to fetch movies that are trending for the current week
- Movie - to fetch primary details of a selected movie and corresponding credits of that movie
- Search - to fetch movies that result from a user search
- Person - to fetch details of a selected cast member from a movie
Axios setup
Axios is used to perform the data fetching, which has a particularly nice feature of automatically converting responses to JSON. Axios can be installed with:
npm install --save axios
Once installed, an Axios instance is created for the Movie DB with a baseURL. While only one API is used in this app, creating a separate subdirectory like src/apis to store various Axios instances scales nicely when multiple APIs are invoked.
/apis/moviedb.js
import axios from 'axios';
export default axios.create({
baseURL: 'https://api.themoviedb.org/3'
});
Action creators
Data fetching is performed in action creators. These are functions that return actions, which are plain objects that contain at least a "type" property and optionally a "payload". All of the action creators are contained and exported from the /actions/index.js file.
While each action creator differs slightly in the arguments it takes in and URL it fetches, they have a very similar overall structure. We'll demonstrate their structure with the fetchNowPlayingMovies
action creator, which as the name suggests, fetches movies that are currently playing in theaters.
fetchNowPlayingMovies
takes in three arguments:
- startDate - the earliest release date to include in fetched movies
- endDate - the latest release date to include in fetched movies
- page - which page of returned results to include (default 1st page)
Since fetching is performed asynchronously, this action creator returns a function rather than an action, as would happen in a synchronous creator. This inner function then uses the dispatch method from the Redux store to dispatch an action. In this case, the dispatched action has a type of 'NOWPLAYING_MOVIES' and a payload that is an array of objects from the Movie DB API, where each object is a movie.
actions/index.js
import moviedb from '../apis/moviedb';
export const fetchNowPlayingMovies = (startDate, endDate, page = 1) =>
async dispatch => {
dispatch(isLoading());
const response = await moviedb.get(
`/discover/movie?api_key=${your-api-key}&language=en-US&
sort_by=popularity.desc&certification_country=US&include_adult=false&
include_video=false&page=${page}&primary_release_date.gte=${startDate}&
primary_release_date.lte=${endDate}&with_original_language=en`
);
dispatch({
type: 'NOWPLAYING_MOVIES',
payload: response.data.results
});
dispatch(isNotLoading());
};
A spinner is triggered on or off by other action creators isLoading
and isNotLoading
in the code above. These action creators return an action that has payload of either true or false that toggles the state of the spinner.
The starting and ending dates are computed in a helper function that returns an array of date strings. The first element in the array is the date one-month ago and the second element is today's date. Hence, "now playing" is defined as movies that were released in the past month.
helper.js
export const nowPlayingDates = () => {
const currentDate = new Date(Date.now());
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const currentDateStr = `${currentDate.getFullYear()}-${currentDate.getMonth() +
1}-${currentDate.getDate()}`;
const oneMonthAgoStr = `${oneMonthAgo.getFullYear()}-${oneMonthAgo.getMonth() +
1}-${oneMonthAgo.getDate()}`;
return [oneMonthAgoStr, currentDateStr];
};
Reducers
Alongside the action creators, corresponding reducers are created to handle slices of state. For example, inside of reducers/movieReducers.js, a function called nowPlayingMoviesReducer
is created that takes in the current state of now playing movies (an empty array to begin with) and updates that slice of state with the payload property of the action with type 'NOWPLAYING_MOVIES':
reducers/movieReducers.js
export const nowPlayingMoviesReducer = (state = [], action) => {
switch (action.type) {
case 'NOWPLAYING_MOVIES':
return action.payload;
default:
return state;
}
};
This slice reducer is transformed to a single reducer inside of reducers/index.js using the combineReducers function from Redux. As seen in the beginning of this post, this single reducing function (aka rootReducer
) is then passed into the createStore
function in src/index.js to generate the state tree.
reducers/index.js
import { combineReducers } from 'redux';
import { nowPlayingMoviesReducer } from './movieReducers';
export default combineReducers({
nowPlayingMovies: nowPlayingMoviesReducer
})
Connected components
Finally, a component called NowPlaying
is created to both fetch and display movies that are now playing. It is connected to the Redux store using the connect function from React-Redux. This connected component has access to the nowPlayingMovies
piece of state thanks to the mapStateToProps function that serves as the first argument to connect()
. The second argument to connect()
grants the connected component access to the data-fetching action creators through the object short-hand form of mapDispatchToProps.
components/NowPlaying.js
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { Spinner } from './Spinner'
import MovieCard from './MovieCard';
import { fetchNowPlayingMovies } from '../actions';
import { nowPlayingDates } from '../helper.js';
const CardGrid = styled.div`
// Styles discussed later in post
`
class NowPlaying extends React.Component {
componentDidMount() {
const [startDate, endDate] = nowPlayingDates();
this.props.fetchNowPlayingMovies(startDate, endDate, page = 1);
}
renderList() {
return this.props.nowPlayingMovies.map(movie => (
<MovieCard movie={movie} key={movie.id} />
));
}
render() {
if (this.props.isLoading) {
return <Spinner text="Loading movies" />;
}
return (
<CardGrid>{this.renderList()}</CardGrid>
)
}
}
const mapStateToProps = state => {
return {
isLoading: state.isLoading,
nowPlayingMovies: state.nowPlayingMovies
};
};
export default connect(
mapStateToProps,
{ fetchNowPlayingMovies }
)(NowPlaying);
When the component first mounts, the range of dates from now to one-month ago is calculated and used to trigger the action creator fetchNowPlayingMovies
with those dates. Recall that that action creator toggles the isLoading
piece of state to render a Spinner
or not. The renderList()
method on this class component is used to return a list of MovieCard
components, where each movie is an object within the array of nowPlayingMovies
slice of state. We'll discuss the styling to MovieCard
and its container - CardGrid
- a little later in this post.
Selectors
The code above for NowPlaying
was refactored to allow users to select how to sort the list of movies that are rendered in this component. Users can sort by most popular (default), highest rated, or most recent, each of which are based on properties attached to each movie (e.g. popularity rating, release date, etc). Computing data from the Redux store is accomplished through selector functions. In this case, sorting arrays of movies in the store based on a sorting key is the derived data that we are after.
The reselect library provides a convenient way to create memoized selectors for apps with a Redux store. It can be installed with
npm install --save reselect
Sorting selectors are created for each part of the app (e.g. Now Playing, Trending, Top Rated) and they have a common structure. Using NowPlaying
as the example, the selector function uses the createSelector
function from reselect to take in two other selectors:
- nowPlayingSelector - returns the
nowPlayingMovies
array from state - sortKeySelector - returns the
sortKey
string from state
to then return a sorted array of now playing movies from the store.
selectors/index.js
import { createSelector } from 'reselect';
const nowPlayingSelector = state => state.nowPlayingMovies;
const sortKeySelector = state => state.sortKey;
export const sortedNowPlayingSelector = createSelector(
nowPlayingSelector,
sortKeySelector,
(movies, sortKey) => {
if (sortKey === 'popularity' || sortKey === 'vote_average') {
return [...movies].sort((a, b) => b[sortKey] - a[sortKey]);
} else if (sortKey === 'release_date') {
return [...movies].sort(
(a, b) => new Date(b[sortKey]) - new Date(a[sortKey])
);
} else {
return movies;
}
}
);
The refactored NowPlaying
component includes a new component SortMenu
that tracks the user-selected sort option (this sets sortKey
in state) to perform this reselection. Below is a snippet reflecting these changes.
components/NowPlaying.js
// new imports
+ import SortMenu from './SortMenu';
+ import { sortedNowPlayingSelector } from '../selectors';
class NowPlaying extends React.Component {
componentDidMount() {
// no refactors
}
renderList() {
// no refactors
}
render() {
if (this.props.isLoading) {
return <Spinner text="Loading movies" />;
}
return (
<div>
+ <SortMenu />
<CardGrid>{this.renderList()}</CardGrid>
</div>
);
}
}
const mapStateToProps = state => {
return {
isLoading: state.isLoading,
+ nowPlayingMovies: sortedNowPlayingSelector(state),
+ sortKey: state.sortKey
};
};
// no refactors to the connect() function
Here's a demo of the selector in action:
Styling
Cards and card grid
The MovieCard
and CardGrid
components referenced in the NowPlaying
snippet above uses a similiar techique to organize items in a grid as was used in News Flash. MovieCard
takes in a movie object and returns a card with a fixed 350px width that contains the movie's poster, title, release date, rating, plot summary, and a link to view more details of the movie. A styled component CardContainer
is created to handle this:
components/MovieCard.js
const CardContainer = styled.div`
display: grid;
grid-template-columns: 150px 200px;
grid-template-areas:
'poster header'
'poster overview'
'poster link';
border: 1px grey solid;
border-radius: 3px;
box-shadow: 1px 2px 2px grey;
transition: all 0.2s ease-in;
background: var(--black);
img {
grid-area: poster;
...
}
.header {
grid-area: header;
...
}
p.overview {
grid-area: overview;
...
}
a {
grid-area: link;
...
}
@media screen and (min-width: 500px) {
grid-template-columns: 150px 260px;
/* additional styles to increase font-size */
/* padding on larger screens */
`
These styles are mobile-first and a media query is created for larger screens (minimum width of 500px) to increase the overall size of the card and the content inside of it.
An array of MovieCard
commponents then serve as grid items to the CardGrid
styled component back in NowPlaying
. CardGrid
auto-fits as many cards as possible based on fixed column widths of 350px (the width of small-screen cards) or 410px (the width of larger-screen cards).
components/NowPlaying.js
const CardGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, 350px);
grid-gap: 1.5em;
justify-content: center;
padding: 1.5em 0;
@media screen and (min-width: 500px) {
grid-template-columns: repeat(auto-fit, 410px);
}
`;
Movie details
When a user clicks to reveal details of a movie, new data is fetched providing specifics of that movie and its crew. This fetch ultimately stores an object called selectedMovie
in the Redux store that is made accessible to the MovieDetails
component. This component is actually comprised of six child components:
- DetailsBackdrop - large backdrop poster image
- DetailsOverview - title, release date, rating, revenue, and plot summary
- DetailsCrew - writer and director information
- DetailsVideos - embedded YouTube trailers and featurettes
- DetailsCast - horizontal gallery of top billed cast members
- DetailsSimilar - horizontal gallery of similar movies
One of the more challenging styling tasks was responsively displaying the backdrop poster. The backdrop is natively sized with a much larger width than height. This makes it naturally fit on larger screens, so its shape needs to transform from rectangular-ish to square-ish for smaller screens. After some trial and error, I settled on just 4 lines of CSS to create a response backdrop poster:
components/DetailsBackdrop.js
const Backdrop = styled.div`
height: 350px;
background-image: url("https://image.tmdb.org/t/p/original${props =>
props.imgPath}");
background-position: center 25%;
background-size: cover;
`;
The Backdrop
styled div is passed a prop of imgPath
, which is a property on the selected movie, to create the URL that is fetched in background-image
. The div is set with a fixed height of 350px and the image inside the div centered on the x-axis and offset 25% below the top of the container. The cover
image size scales the backdrop poster as large as needed without stretching it and crops it so that no empty space exists in the container.