Cách tôi đã cấu trúc một ứng dụng React một trang

Với cấu trúc dữ liệu, thành phần và tích hợp với Redux

Gần đây tôi đã xây dựng một ứng dụng một trang tương tác với máy chủ API JSON phụ trợ. Tôi đã chọn sử dụng React để hiểu sâu hơn về các nguyên tắc cơ bản của React và cách mỗi công cụ có thể giúp xây dựng một giao diện người dùng có thể mở rộng.

Ngăn xếp của ứng dụng này bao gồm:

  • Giao diện người dùng với React / Redux
  • Máy chủ API JSON phụ trợ với Sinatra, được tích hợp với Postgres để duy trì cơ sở dữ liệu
  • Ứng dụng khách API tìm nạp dữ liệu từ API OMDb, được viết bằng Ruby

Đối với bài đăng này, chúng tôi sẽ giả định rằng chúng tôi đã hoàn thành phần phụ trợ. Vì vậy, hãy tập trung vào cách các quyết định thiết kế được thực hiện trên giao diện người dùng.

Lưu ý phụ: Các quyết định được trình bày ở đây chỉ mang tính chất tham khảo và có thể thay đổi tùy thuộc vào nhu cầu của đơn đăng ký của bạn. Ví dụ về ứng dụng OMDb Movie Tracker được sử dụng ở đây để trình diễn.

Ứng dụng

Ứng dụng này bao gồm một biểu mẫu đầu vào tìm kiếm. Người dùng có thể nhập tiêu đề phim để trả về kết quả phim từ OMDb. Người dùng cũng có thể lưu một bộ phim có xếp hạng và bình luận ngắn vào danh sách yêu thích.

Để xem ứng dụng cuối cùng, bấm vào đây. Để xem mã nguồn, bấm vào đây.

Khi người dùng tìm kiếm một bộ phim trên trang chủ, nó sẽ giống như sau:

Để đơn giản, chúng tôi sẽ chỉ tập trung vào việc thiết kế các tính năng cốt lõi của ứng dụng trong bài viết này. Bạn cũng có thể bỏ qua Phần II: Reduxcủa bộ truyện.

Cấu trúc dữ liệu

Xác định cấu trúc dữ liệu phù hợp phải là một trong những khía cạnh quan trọng nhất của việc thiết kế ứng dụng. Đây nên là bước đầu tiên, vì nó không chỉ xác định cách giao diện người dùng sẽ hiển thị các phần tử mà còn xác định cách máy chủ API sẽ trả lại các phản hồi JSON.

Đối với ứng dụng này, chúng tôi sẽ cần hai thông tin chính để hiển thị giao diện người dùng của chúng tôi một cách chính xác: một kết quả phim duy nhấtdanh sách các phim yêu thích .

Đối tượng kết quả phim

Một kết quả phim duy nhất sẽ chứa thông tin như tiêu đề, năm, mô tả và hình ảnh áp phích. Với điều này, chúng ta cần xác định một đối tượng có thể lưu trữ các thuộc tính này:

{ "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"}

Các posterbất động sản chỉ đơn giản là một URL đến ảnh áp phích sẽ được hiển thị trong kết quả. Nếu không có áp phích nào cho bộ phim đó, nó sẽ là “N / A”, chúng tôi sẽ hiển thị một trình giữ chỗ. Chúng tôi cũng sẽ cần một imdbIDthuộc tính để xác định duy nhất từng bộ phim. Điều này rất hữu ích để xác định xem kết quả phim đã tồn tại trong danh sách yêu thích hay chưa. Chúng ta sẽ khám phá sau về cách nó hoạt động.

Danh sách yêu thích

Danh sách yêu thích sẽ chứa tất cả các bộ phim được lưu dưới dạng yêu thích. Danh sách sẽ trông giống như sau:

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

Hãy nhớ rằng chúng ta sẽ cần tra cứu một bộ phim cụ thể từ danh sách và độ phức tạp về thời gian cho phương pháp này là O (N) . Mặc dù nó hoạt động tốt với các bộ dữ liệu nhỏ hơn, nhưng hãy tưởng tượng bạn phải tìm kiếm một bộ phim trong danh sách yêu thích tăng lên vô thời hạn.

Với ý nghĩ này, tôi đã chọn sử dụng một bảng băm với các khóa imdbIDvà giá trị là các đối tượng phim ưa thích:

{ 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!", }}

Với điều này, chúng ta có thể tra cứu một bộ phim trong danh sách yêu thích trong O (1) thời gian của nó imdbID.

Lưu ý: độ phức tạp thời gian chạy có lẽ sẽ không thành vấn đề trong hầu hết các trường hợp vì các bộ dữ liệu thường nhỏ ở phía máy khách. Dù sao thì chúng ta cũng sẽ thực hiện các thao tác cắt và sao chép (cũng là O (N)) trong Redux. Nhưng là một kỹ sư, thật tốt khi nhận thức được những tối ưu hóa tiềm năng mà chúng tôi có thể thực hiện.

Các thành phần

Các thành phần là trung tâm của React. Chúng tôi sẽ cần xác định cái nào sẽ tương tác với Redux store và cái nào chỉ dùng để trình bày. Chúng tôi cũng có thể sử dụng lại một số thành phần trình bày. Hệ thống phân cấp thành phần của chúng tôi sẽ trông giống như sau:

Trang chính

Chúng tôi chỉ định thành phần Ứng dụng của mình ở cấp cao nhất. Khi đường dẫn gốc được truy cập, nó cần hiển thị SearchContainer . Nó cũng cần hiển thị các thông báo flash cho người dùng và xử lý định tuyến phía máy khách.

Các SearchContainer sẽ lấy kết quả phim từ cửa hàng Redux của chúng tôi, cung cấp thông tin như đạo cụ để MovieItem cho rendering. Nó cũng sẽ thực hiện một hành động tìm kiếm khi người dùng gửi một tìm kiếm trong SearchInputForm . Nhiều hơn trên Redux sau.

Thêm vào biểu mẫu yêu thích

Khi người dùng nhấp vào nút “Thêm vào Mục ưa thích”, chúng tôi sẽ hiển thị AddFavoriteForm , một thành phần được kiểm soát.

Chúng tôi liên tục cập nhật trạng thái của nó bất cứ khi nào người dùng thay đổi xếp hạng hoặc văn bản đầu vào trong khu vực văn bản nhận xét. Điều này rất hữu ích cho việc xác nhận khi gửi biểu mẫu.

Các RatingForm có trách nhiệm làm cho các ngôi sao màu vàng khi người dùng nhấp chuột vào chúng. Nó cũng thông báo giá trị xếp hạng hiện tại cho AddFavoriteForm .

Tab yêu thích

Khi người dùng nhấp vào tab “Yêu thích”, Ứng dụng sẽ hiển thị Người lưu trữ yêu thích .

Các FavoritesContainer là trách nhiệm thu hồi danh sách yêu thích từ các cửa hàng Redux. Nó cũng thực hiện các hành động khi người dùng thay đổi xếp hạng hoặc nhấp vào nút “Xóa”.

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

Tôi là một kỹ sư phần mềm ở NYC và là đồng sáng tạo của SpaceCraft. Tôi có kinh nghiệm trong việc thiết kế các ứng dụng một trang, đồng bộ hóa trạng thái giữa nhiều máy khách và triển khai các ứng dụng có thể mở rộng với Docker.

Tôi hiện đang tìm kiếm cơ hội toàn thời gian tiếp theo của mình! Vui lòng liên hệ nếu bạn nghĩ rằng tôi sẽ phù hợp với nhóm của bạn.