How I architected a single-page React application

Planning for scalability, performance and smooth user experience

React app architecture

When building a single-page application (SPA) with React, taking time upfront to thoughtfully architect the application can pay huge dividends in terms of development velocity, app performance, user experience, and ease of future enhancement. In this post, I‘ll share my approach and learnings from recently building a movie tracking SPA that searches the OMDb API, allows the user to save and rate favorites, and uses React, Redux, and thunks to power the frontend.

Defining the core features and data model

Before diving into code, I find it valuable to step back and define the key functionality the app needs to support, and to design the shape of the core data driving that functionality. For the movie app, the main features are:

  • Allow searching for movies by title and displaying the results
  • Enable saving movies to a favorites list
  • Support rating and commenting on favorite movies
  • Allow removing movies from favorites

This leads to the need for two primary data structures:

Movie result object

An object representing a single movie, with properties:


{
  "title": "Star Wars: Episode IV - A New Hope",
  "year": "1977", 
  "plot": "Luke Skywalker joins forces with a Jedi Knight...",
  "poster": "https://m.media-amazon.com/images/M/MV5BNzVlY2MwMjktM2E4OS00Y2Y3LWE3ZjctYzhkZGM3YzA1ZWM2XkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_SX300.jpg",
  "imdbID": "tt0076759"
}

The poster property contains a URL to the movie‘s poster image. The imdbID uniquely identifies each movie and will prove useful later.

Favorites list

To store the user‘s favorite movies, we could use an array:


[
  { 
    title: "Star Wars", 
    year: "1977",
    imdbID: "tt0076759",
    rating: 4, 
    comment: "My favorite childhood movie!" 
  },
  { 
    title: "Back to the Future", 
    year: "1985",
    imdbID: "tt0088763",  
    rating: 5,
    comment: "Classic!"
  }
]  

However, this would require an O(n) search by imdbID to check if a movie is already favorited. Since that‘s a common operation, we can optimize it to O(1) by using a hash table with imdbID keys:


{
  "tt0076759": {
    title: "Star Wars", 
    year: "1977",
    imdbID: "tt0076759",
    rating: 4,
    comment: "My favorite childhood movie!"
  },
  "tt0088763": { 
    title: "Back to the Future",
    year: "1985", 
    imdbID: "tt0088763",
    rating: 5, 
    comment: "Classic!"
  }
}

Defining the component hierarchy

With the data model sketched out, I moved on to designing the component hierarchy. The app would need:

  • A top-level App component to serve as the main page
  • A SearchContainer to manage searching and displaying movie results
    • Uses a MovieItem component to render each result
    • Displays an AddFavoriteForm to let the user save a favorite
      • The form includes a RatingForm component for setting a rating
  • A FavoritesContainer to display the favorites list
    • Also uses MovieItem for each favorite
    • Includes FavoritesInfo and RatingForm to show/edit favorite details

Here‘s how that hierarchy looks visually:

Component hierarchy

Designing the Redux store

To manage the application state, I chose to use Redux. The store would need:

  • A search reducer for the current search result
  • A favorites reducer for the saved favorites list
  • A status reducer to track request statuses for providing UI feedback

The reducers would be combined together like:


//store.js

import { 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) );

I also applied the Redux Thunk middleware to support dispatching functions (thunks) to handle asynchronous actions like AJAX requests.

Search flow

With the Redux store configured, I could implement the search flow:

  1. User submits a search
  2. The SearchContainer dispatches a thunk action
  3. The thunk action:
    1. Dispatches a searchRequest action to update status to pending
    2. Queries the OMDb API using an apiClient module
    3. Dispatches a searchSuccess action on resolution, or searchFailure on rejection
  4. The search reducer updates its state based on the dispatched action
  5. The SearchContainer re-renders based on the new Redux store state

Here‘s how those actions and reducer look:


//searchActions.js

import apiClient from ‘../apiClient‘;

export const searchRequest = () => ({ type: ‘SEARCH_REQUEST‘
});

export const searchSuccess = (result) => ({ type: ‘SEARCH_SUCCESS‘, result });

export const searchFailure = (error) => ({ type: ‘SEARCH_FAILURE‘, error
});

export function search(title) { return (dispatch) => { dispatch(searchRequest());

apiClient.searchMovies(title)
  .then(response => dispatch(searchSuccess(response.data)))  
  .catch(error => dispatch(searchFailure(error.message)));

};
}

//searchReducer.js

const initialState = {
title: ‘‘,
year: ‘‘,
plot: ‘‘,
poster: ‘‘,
imdbID: ‘‘
};

export default (state = initialState, action) => {
switch (action.type) {
case ‘SEARCH_SUCCESS‘:
return action.result;
default:
return state;
}
};

And the SearchContainer connects to the Redux store like:


//SearchContainer.js

import React from ‘react‘; import { connect } from ‘react-redux‘; import { search } from ‘../actions/searchActions‘; import MovieItem from ‘./MovieItem‘; import AddFavoriteForm from ‘./AddFavoriteForm‘;

const SearchContainer = (props) => ( <div> <form onSubmit={...}> <input type="text" value={props.query} onChange={...}
/> <button type="submit">Search</button> </form>

{props.status === ‘PENDING‘ && <div>Searching...</div>}

{props.status === ‘SUCCESS‘ && (
  <div>
    <MovieItem movie={props.result} />
    <AddFavoriteForm />  
  </div>
)}

{props.status === ‘FAILURE‘ && (
  <div>Error: {props.error}</div>  
)}

</div>
);

const mapState = (state) => ({
query: state.search.query,
result: state.search.result,
status: state.status.search,
error: state.status.searchError
});

const mapDispatch = {
search
};

export default connect(mapState, mapDispatch)(SearchContainer);

This allows displaying a pending message while searching, the movie result on success, or an error on failure.

Favorites CRUD actions

The flow for adding, updating, and removing favorites is very similar to search:

  1. User takes an action (e.g. click "Save Favorite")
  2. The relevant component dispatches a thunk action
  3. The thunk action:
    1. Calls the API
    2. Dispatches a success or failure action
  4. The favorites reducer updates its state
  5. Connected components re-render with the new data

By keeping the reducers pure and following the same pattern of thunk actions for AJAX requests, the various CRUD operations are implemented in a consistent way. For example, here‘s a thunk action for saving a favorite:

  
//favoritesActions.js

import apiClient from ‘../apiClient‘;

export function addFavorite(movie) { return (dispatch) => { apiClient.addFavorite(movie) .then(response => dispatch(addFavoriteSuccess(response.data))) .catch(error => dispatch(addFavoriteFailure(error.message))); }; }

And the corresponding reducer logic:


//favoritesReducer.js 

const initialState = {};

export default (state = initialState, action) => { switch (action.type) { case ‘ADD_FAVORITE_SUCCESS‘: return { ...state,

  };  
case ‘REMOVE_FAVORITE_SUCCESS‘:
  const newState = { ...state };
  delete newState[action.imdbID];
  return newState;
default:
  return state;

}
};

Note the use of object spread syntax to avoid mutating the state, keeping the reducer pure.

Connecting components

With all the Redux pieces in place, the React components can connect to the store to access the state and dispatch actions using the connect function. For example, here‘s how FavoritesContainer connects:


//FavoritesContainer.js

import React from ‘react‘; import { connect } from ‘react-redux‘; import { removeFavorite, updateRating } from ‘../actions/favoritesActions‘; import MovieItem from ‘./MovieItem‘; import FavoriteInfo from ‘./FavoriteInfo‘;

const FavoritesContainer = (props) => ( <div> <h2>Favorites</h2> {Object.values(props.favorites).map(movie => ( <div key={movie.imdbID}> <MovieItem movie={movie} /> <FavoriteInfo movie={movie} onRemove={() => props.removeFavorite(movie.imdbID)} onRatingChange={(rating) => props.updateRating(movie.id, rating)} /> </div>
))} </div> );

const mapState = (state) => ({ favorites: state.favorites
});

const mapDispatch = { removeFavorite, updateRating };

export default connect(mapState, mapDispatch)(FavoritesContainer);

The component receives favorites from the store state, and removeFavorite and updateRating are bound to dispatch. This keeps the component focused on presentation and leaves the state management to Redux.

Conclusion

Architecting a non-trivial single-page app with React and Redux takes careful thought and planning. Designing the application state shape, component hierarchy, and application flow in advance allows building out features in a structured, scalable way. Using pure reducer functions, thunks for async actions, and controlled components leads to predictable state updates. With this architecture, adding additional features like pagination, filtering, authentication and route-based views becomes much more straightforward. Taking the time to get the core architecture right pays huge dividends in the long run in terms of performance, scalability and maintainability.

Similar Posts