단일 페이지 React 애플리케이션을 설계 한 방법

데이터 구조, 구성 요소 및 Redux와의 통합

최근에 백엔드 JSON API 서버와 상호 작용하는 단일 페이지 애플리케이션을 구축했습니다. 저는 React를 사용하여 React 기본 사항과 각 도구가 확장 가능한 프런트 엔드를 구축하는 데 어떻게 도움이 될 수 있는지에 대한 이해를 높이기로 선택했습니다.

이 애플리케이션의 스택은 다음으로 구성됩니다.

  • React / Redux를 사용한 프런트 엔드
  • 데이터베이스 지속성을 위해 Postgres와 통합 된 Sinatra가 포함 된 백엔드 JSON API 서버
  • Ruby로 작성된 OMDb API에서 데이터를 가져 오는 API 클라이언트

이 게시물에서는 백엔드가 완료되었다고 가정합니다. 따라서 프런트 엔드에서 디자인 결정을 내리는 방법에 중점을 두겠습니다.

참고 : 여기에 제시된 결정은 참조 용일 뿐이며 응용 프로그램의 요구 사항에 따라 달라질 수 있습니다. 여기에서는 데모 용 OMDb Movie Tracker 앱의 예를 사용합니다.

응용 프로그램은 검색 입력 양식으로 구성됩니다. 사용자는 OMDb에서 영화 결과를 반환하기 위해 영화 제목을 입력 할 수 있습니다. 사용자는 또한 즐겨 찾기 목록에 평가 및 짧은 댓글과 함께 영화를 저장할 수 있습니다.

최종 앱을 보려면 여기를 클릭하십시오. 소스 코드를 보려면 여기를 클릭하십시오.

사용자가 홈페이지에서 영화를 검색하면 다음과 같습니다.

단순함을 위해이 기사에서는 애플리케이션의 핵심 기능 디자인에만 초점을 맞출 것입니다. Part II : Redux로 건너 뛸 수도 있습니다 .시리즈의.

데이터 구조

적절한 데이터 구조를 정의하는 것은 앱 디자인의 가장 중요한 측면 중 하나 여야합니다. 이는 프런트 엔드가 요소를 렌더링하는 방법뿐만 아니라 API 서버가 JSON 응답을 반환하는 방법도 결정하므로 첫 번째 단계가되어야합니다.

이 앱의 경우 UI를 올바르게 렌더링하려면 두 가지 주요 정보 , 즉 단일 영화 결과즐겨 찾는 영화 목록이 필요합니다 .

영화 결과 개체

단일 영화 결과에는 제목, 연도, 설명 및 포스터 이미지와 같은 정보가 포함됩니다. 이를 통해 다음 속성을 저장할 수있는 객체를 정의해야합니다.

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

poster속성은 단순히 결과에 표시 될 포스터 이미지에 대한 URL입니다. 해당 영화에 사용할 수있는 포스터가없는 경우 "N / A"로 표시되며 자리 표시자를 표시합니다. 또한 imdbID각 영화를 고유하게 식별 하는 속성이 필요합니다 . 이것은 영화 결과가 즐겨 찾기 목록에 이미 존재하는지 여부를 결정하는 데 유용합니다. 나중에 어떻게 작동하는지 살펴 보겠습니다.

즐겨 찾기 목록

즐겨 찾기 목록에는 즐겨 찾기로 저장된 모든 영화가 포함됩니다. 목록은 다음과 같습니다.

[ { title: "Star Wars", year: "1977", ..., rating: 4 }, { title: "Avatar", year: "2009", ..., rating: 5 }]

목록에서 특정 영화를 찾아야하며이 접근 방식의 시간 복잡도는 O (N) 입니다. 작은 데이터 세트에서는 잘 작동하지만, 무한히 늘어나는 즐겨 찾기 목록에서 영화를 검색해야한다고 상상해보십시오.

이를 염두에두고 키 imdbID와 값 이있는 해시 테이블을 즐겨 찾기 영화 개체로 선택했습니다.

{ tt0076759: { title: "Star Wars: Episode IV - A New Hope", year: "1977", plot: "...", poster: "...", rating: "4", comment: "May the force be with you!", }, tt0499549: { title: "Avatar", year: "2009", plot: "...", poster: "...", rating: "5", comment: "Favorite movie!", }}

이를 통해 O (1) 시간 의 즐겨 찾기 목록에서 imdbID.

참고 : 데이터 세트가 일반적으로 클라이언트 측에서 작기 때문에 런타임 복잡성은 대부분의 경우 중요하지 않을 것입니다. 어쨌든 Redux에서 슬라이싱과 복사 (또한 O (N) 작업)를 수행 할 것입니다. 하지만 엔지니어로서 우리가 수행 할 수있는 잠재적 인 최적화를 알고있는 것이 좋습니다.

구성품

컴포넌트는 React의 핵심입니다. 우리는 Redux 스토어와 상호 작용할 것과 프레젠테이션 전용 인 것을 결정해야합니다. 또한 일부 프레젠테이션 구성 요소도 재사용 할 수 있습니다. 구성 요소 계층 구조는 다음과 같습니다.

메인 페이지

우리 는 최상위 수준에서 구성 요소를 지정 합니다. 루트 경로를 방문하면 SearchContainer 를 렌더링해야합니다 . 또한 사용자에게 플래시 메시지를 표시하고 클라이언트 측 라우팅을 처리해야합니다.

SearchContainer은 에 소품 등의 정보를 제공, 우리의 돌아 오는 점에서 영화의 결과를 검색합니다 MovieItem 렌더링. 또한 사용자가 SearchInputForm 에서 검색을 제출할 때 검색 작업을 전달합니다 . 나중에 Redux에 대해 자세히 알아보십시오.

즐겨 찾기 양식에 추가

사용자가 "즐겨 찾기에 추가"버튼을 클릭 하면 제어되는 구성 요소 인 AddFavoriteForm 이 표시됩니다 .

We are constantly updating its state whenever a user changes the rating or input text in the comment text area. This is useful for validation upon form submission.

The RatingForm is responsible to render the yellow stars when the user clicks on them. It also informs the current rating value to AddFavoriteForm.

Favorites Tab

When a user clicks on the “Favorites” tab, the App renders FavoritesContainer.

The FavoritesContainer is responsible for retrieving the favorites list from the Redux store. It also dispatches actions when a user changes a rating or clicks on the “Remove” button.

Our MovieItem and FavoritesInfo are simply presentational components that receive props from FavoritesContainer.

We’ll reuse the RatingForm component here. When a user clicks on a star in the RatingForm, the FavoritesContainer receives the rating value and dispatches an update rating action to the Redux store.

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.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))

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.js
const 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.jsexport 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. Thisfunction is responsible for fetching the search result and dispatching a searchSuccess action:

//searchActions.jsimport 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 beforehand, and you can read more about how I set up the API client here. 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.js
import 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:

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:

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.

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.jsconst 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 more 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.js
export 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) => (   props.search(title) } /> { (props.searchStatus === 'SUCCESS') ?  : null } { (props.searchStatus === 'PENDING') ?    : null } { (props.searchStatus === 'ERROR') ?  

{ props.searchError }

: null } );
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.js
export 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.jsimport 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.

To read more about how I handled the flow to display flash messages, click here.

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, we 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, we can streamline managing the flow of providing UI feedback to user actions. Ultimately, how we approach the design will have an impact 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.