How I Architected a Single-Page React Application: Part II — Redux

Gooi Ying Chyi
10 min readMar 16, 2019

You can read Part I of the series here on designing Data Structures, Components, and APIs. You can also view the final app here.

Background photo by Sven Mieke on Unsplash

In building a scalable application, we’d like a mechanism where we can more effectively manage our state. Fortunately, we have React Redux. The syntax is more natural and it provides a standardized way for components to access the Redux store and render state changes. Without React Redux, we’ll have to manually subscribe the components to the store and access the store through other means, such as contextTypes.

Redux Store

Our Redux store will include reducers that handle the search and favorites actions. Additionally, we’ll need to include a status reducer to track state changes when a user initiates an action. We’ll explore more on the status reducer later.

//store.jsimport { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from "redux-thunk";
import search from './reducers/searchReducer';
import favorites from './reducers/favoritesReducer';
import status from './reducers/statusReducer';
export default createStore(
combineReducers({
search,
favorites,
status
}),
{},
applyMiddleware(thunk)
)

We’ll also apply the Redux Thunk middleware right away. We’ll go more into detail on that later. Now, let’s figure out how we manage the state changes when a user submits a search.

Search Reducer

When a user performs a search action, we want to update the store with a new search result via searchReducer. We can then render our components accordingly. The general flow of events looks like this:

We’ll treat “Get search result” as a black box for now. We’ll explore how that works later with Redux Thunk. Now, let’s implement the reducer function.

//searchReducer.jsconst initialState = {
"title": "",
"year": "",
"plot": "",
"poster": "",
"imdbID": "",
}
export default (state = initialState, action) => {
if (action.type === 'SEARCH_SUCCESS') {
state = action.result;
}
return state;
}

The initialState will represent the data structure defined earlier as a single movie result object. In the reducer function, we handle the action where a search is successful. If the action is triggered, we simply reassign the state to the new movie result object.

//searchActions.js
export const searchSuccess = (result) => ({
type: 'SEARCH_SUCCESS', result
});

We define an action called searchSuccess that takes in a single argument, the movie result object, and returns an action object of type “SEARCH_SUCCESS”. We will dispatch this action upon a successful search API call.

Redux Thunk: Search

Let’s explore how the “Get search result” from earlier works. First, we need to make a remote API call to our backend API server. When the request receives a successful JSON response, we’ll dispatch the searchSuccess action along with the payload to searchReducer.

Knowing that we’ll need to dispatch after an asynchronous call completes, we’ll make use of Redux Thunk. Thunk comes into play for making multiple dispatches or delaying a dispatch. With Thunk, our updated flow of events looks like this:

For this, we define a function that takes in a single argument title and serves as the initial search action. This function is responsible for fetching the search result and dispatching a searchSuccess action:

//searchActions.js
import apiClient from '../apiClient';
...export function search(title) {
return (dispatch) => {
apiClient.query(title)
.then(response => {
dispatch(searchSuccess(response.data))
});
}
}

We’ve set up our API client from earlier. The apiClient.query method simply performs an AJAX GET request to our backend server and returns a Promise with the response data.

We can then connect this function as an action dispatch to our SearchContainer component:

//SearchContainer.jsimport React from 'react';
import { connect } from 'react-redux';
import { search } from '../actions/searchActions';
...const mapStateToProps = (state) => (
{
result: state.search,
}
);
const mapDispatchToProps = (dispatch) => (
{
search(title) {
dispatch(search(title))
},
}
);
export default connect(mapStateToProps, mapDispatchToProps)(SearchContainer);

When a search request succeeds, our SearchContainer component will render the movie result:

Left: the app renders the movie result | Right: a successful search request

Handling Other Search Statuses

Now we have our search action working properly and connected to our SearchContainer component, we’d like to handle other cases other than a successful search.

Search request pending

When a user submits a search, we’ll display a loading animation to indicate that the search request is pending:

The app displays a spinner animation when waiting for the search result

Search request succeeds

If the search fails, we’ll display an appropriate error message to the user. This is useful to provide some context. A search failure could happen in cases where a movie title is not available, or our server is experiencing issues communicating with the OMDb API.

When a movie title is not found in OMDb, we display an error message

To handle different search statuses, we’ll need a way to store and update the current status along with any error messages.

Status Reducer

The statusReducer is responsible for tracking state changes whenever a user performs an action. The current state of an action can be represented by one of the three “statuses”:

  • Pending (when a user first initiates the action)
  • Success (when a request returns a successful response)
  • Error (when a request returns an error response)

With these statuses in place, we can render different UIs based on the current status of a given action type. In this case, we’ll focus on tracking the status of the search action.

We’ll start by implementing the statusReducer. For the initial state, we need to track the current search status and any errors:

// statusReducer.js
const initialState = {
search: '', // status of the current search
searchError: '', // error message when a search fails
}

Next, we need to define the reducer function. Whenever our SearchContainer dispatches a “SEARCH_[STATUS]” action, we will update the store by replacing the search and searchError properties.

// statusReducer.js...export default (state = initialState, action) => {
const actionHandlers = {
'SEARCH_REQUEST': {
search: 'PENDING',
searchError: '',
},
'SEARCH_SUCCESS': {
search: 'SUCCESS',
searchError: '',
},
'SEARCH_FAILURE': {
search: 'ERROR',
searchError: action.error,
},
}
const propsToUpdate = actionHandlers[action.type];
state = Object.assign({}, state, propsToUpdate);
return state;
}

We use an actionHandlers hash table here since we are only replacing the state’s properties. Furthermore, it improves readability than using if/else or case statements.

With our statusReducer in place, we can render the UI based on different search statuses. We will update our flow of events to this:

We now have additional searchRequest and searchFailure actions available to dispatch to the store:

//searchActions.jsexport const searchRequest = () => ({
type: 'SEARCH_REQUEST'
});
export const searchFailure = (error) => ({
type: 'SEARCH_FAILURE', error
});

To update our search action, we will dispatch searchRequest immediately and will dispatch searchSuccess or searchFailure based on the eventual success or failure of the Promise returned by Axios:

//searchActions.js...export function search(title) {
return (dispatch) => {
dispatch(searchRequest());
apiClient.query(title)
.then(response => {
dispatch(searchSuccess(response.data))
})
.catch(error => {
dispatch(searchFailure(error.response.data))
});
}
}

We can now connect the search status state to our SearchContainer, passing it as a prop. Whenever our store receives the state changes, our SearchContainer renders a loading animation, an error message, or the search result:

//SearchContainer.js...(imports omitted)const SearchContainer = (props) => (
<main id='search-container'>
<SearchInputForm
placeholder='Search movie title...'
onSubmit={ (title) => props.search(title) }
/>
{
(props.searchStatus === 'SUCCESS')
? <MovieItem
movie={ props.result }
...(other props)
/>
: null
}
{
(props.searchStatus === 'PENDING')
? <section className='loading'>
<img src='../../images/loading.gif' />
</section>
: null
}
{
(props.searchStatus === 'ERROR')
? <section className='error'>
<p className='error'>
<i className="red exclamation triangle icon"></i>
{ props.searchError }
</p>
</section>
: null
}
</main>
);
const mapStateToProps = (state) => (
{
searchStatus: state.status.search,
searchError: state.status.searchError,
result: state.search,
}
);
...

Favorites Reducer

We’ll need to handle CRUD actions performed by a user on the favorites list. Recalling from our API endpoints earlier, we’d like to allow users to perform the following actions and update our store accordingly:

  • Save a movie into the favorites list
  • Retrieve all favorited movies
  • Update a favorite’s rating
  • Delete a movie from the favorites list

To ensure that the reducer function is pure, we simply copy the old state into a new object together with any new properties usingObject.assign. Note that we only handle actions with types of _SUCCESS:

//favoritesReducer.jsexport default (state = {}, action) => {
switch (action.type) {
case 'SAVE_FAVORITE_SUCCESS':
state = Object.assign({}, state, action.favorite);
break;
case 'GET_FAVORITES_SUCCESS':
state = action.favorites;
break;
case 'UPDATE_RATING_SUCCESS':
state = Object.assign({}, state, action.favorite);
break;
case 'DELETE_FAVORITE_SUCCESS':
state = Object.assign({}, state);
delete state[action.imdbID];
break;
default: return state;
}
return state;
}

We’ll leave the initialState as an empty object. The reason is that if our initialState contains placeholder movie items, our app will render them immediately before waiting for the actual favorites list response from our backend API server.

From now on, each of the favorites action will follow a general flow of events illustrated below. The pattern is similar to the search action in the previous section, except right now we’ll skip handling any “PENDING” status.

Save Favorites Action

Take the save favorites action for example. The function makes an API call to with our apiClient and dispatches either a saveFavoriteSuccess or a saveFavoriteFailure action, depending on whether or not we receive a successful response:

//favoritesActions.js
import apiClient from '../apiClient';
export const saveFavoriteSuccess = (favorite) => ({
type: 'SAVE_FAVORITE_SUCCESS', favorite
});
export const saveFavoriteFailure = (error) => ({
type: 'SAVE_FAVORITE_FAILURE', error
});
export function save(movie) {
return (dispatch) => {
apiClient.saveFavorite(movie)
.then(res => {
dispatch(saveFavoriteSuccess(res.data))
})
.catch(err => {
dispatch(saveFavoriteFailure(err.response.data))
});
}
}

We can now connect the save favorite action to AddFavoriteForm through React Redux.

Displaying Flash Messages

Displaying flash messages is useful to give feedback in response to user actions. For instance, we’ll display a “Movie saved to favorites list!” message after a user successfully saves a movie into the favorites list:

We can also display error messages if the save is unsuccessful — such as when the server is experiencing issues.

To implement this feature, we’ll first have to set up the tracking of favorites statuses.

Handling Save Favorites Statuses

We’ll need to track the saveFavorite status along with any errors. saveFavoriteError is assigned an object. This is because we anticipate an error object to be returned from our backend API server.

We’ll also need to handle the SAVE_FAVORITE_SUCCESS, SAVE_FAVORITE_FAILURE actions in our reducer function. To reset the favorite statuses, we’ll add a handler to RESET_SAVE_FAVORITE:

//statusReducer.jsconst initialState = {
search: '',
searchError: '',
saveFavorite: '',
saveFavoriteError: {},

}
export default (state = initialState, action) => {
const actionHandlers = {
...(search actions handlers omitted)
'SAVE_FAVORITE_SUCCESS': {
saveFavorite: 'SUCCESS',
saveFavoriteError: '',
},
'SAVE_FAVORITE_FAILURE': {
saveFavorite: 'ERROR',
saveFavoriteError: action.error,
},
'RESET_SAVE_FAVORITE': {
saveFavorite: '',
saveFavoriteError: '',
},
}
const propsToUpdate = actionHandlers[action.type];
state = Object.assign({}, state, propsToUpdate);
return state;
}

Triggering App to render flash messages

In order to render flash messages, we’ll need to inform our App component that the current saveFavorite status has been changed. We’ll first need to connect the state as a prop to App. Once the App is subscribed to the changes, we’ll implement our logic to render the appropriate flash message within componentDidUpdate . The flow of events looks something like:

In App’s componentDidUpdate method, we’d like to display different flash messages upon a status change:

//App.js...class App extends React.Component {

...
componentDidUpdate = () => {
const props = this.props;
if (props.saveFavorite === 'SUCCESS') {
this.flashMessage('Movie saved to favorites list!');
this.hideForm();
props.resetSaveStatus();
// prevent other actions from triggering the same flash message again
}
if (props.saveFavorite === 'ERROR') {
this.flashErrorMessage(props.saveError);
props.resetSaveStatus();
}
}
...
}
const mapStateToProps = (state) => (
{
saveFavorite: state.status.saveFavorite,
saveError: state.status.saveFavoriteError,

}
)
const mapDispatchToProps = (dispatch) => (
{
resetSaveStatus() {
dispatch(() => ({ type: 'RESET_SAVE_FAVORITE' }))
},

getAllFavorites() {
dispatch(getAll())
},
}
)
export default connect(mapStateToProps, mapDispatchToProps)(App);

We’ll hide the add favorites form upon a successful save. To prevent triggering the same flash message again by other actions, we’ll also need to reset any status associated with the save favorites action via props.resetSaveStatus().

Conclusion

Designing the frontend of an application requires some forethought, even when using a popular JavaScript library such as React. By thinking about how the data structures, components, APIs, and state management work as a whole in the frontend of an application, one can better anticipate edge cases and effectively fix errors when they arise. By using certain design patterns such as controlled components, Redux, and handling AJAX workflow using Thunk, one can understand why such tools are important in handling the flow of providing UI feedback to user actions.

I enjoyed writing this post as much as building this application. The depth of learning does not always come from how many features you built in an application, it comes from how you approach the design — and its consideration on usability, clarity, and future scalability.

References

Fullstack React: The Complete Guide to ReactJS and Friends

About me

I am a software engineer located in NYC and co-creator of SpaceCraft. I have experience in designing single-page applications, synchronizing state between multiple clients, and deploying scalable applications with Docker.

I am currently looking for my next full-time opportunity! Please get in touch if you think that I will be a good fit for your team.

📝 Read this story later in Journal.

🗞 Wake up every Sunday morning to the week’s most noteworthy Tech stories, opinions, and news waiting in your inbox: Get the noteworthy newsletter >

--

--