Πώς έφτιαξα μια εφαρμογή React μίας σελίδας

Με δομές δεδομένων, στοιχεία και ενοποίηση με το Redux

Πρόσφατα δημιούργησα μια εφαρμογή μιας σελίδας που αλληλεπιδρά με διακομιστή JSON API backend Επέλεξα να χρησιμοποιήσω το React για να εμβαθύνω την κατανόησή μου για τις βασικές αρχές του React και πώς κάθε εργαλείο μπορεί να βοηθήσει στην οικοδόμηση ενός επεκτάσιμου frontend.

Η στοίβα αυτής της εφαρμογής αποτελείται από:

  • Frontend με React / Redux
  • Ένας διακομιστής backend JSON API με Sinatra, ενσωματωμένος με Postgres για επιμονή στη βάση δεδομένων
  • Ένα πρόγραμμα-πελάτης API που λαμβάνει δεδομένα από το OMDb API, γραμμένο σε Ruby

Για αυτήν την ανάρτηση, θα υποθέσουμε ότι έχουμε συμπληρώσει το backend. Ας επικεντρωθούμε λοιπόν στο πώς λαμβάνονται οι αποφάσεις σχεδιασμού στο frontend.

Συμπληρωματική σημείωση: Οι αποφάσεις που παρουσιάζονται εδώ είναι μόνο για αναφορά και ενδέχεται να διαφέρουν ανάλογα με τις ανάγκες της αίτησής σας. Ένα παράδειγμα εφαρμογής OMDb Movie Tracker χρησιμοποιείται εδώ για επίδειξη.

Η εφαρμογή

Η εφαρμογή αποτελείται από μια φόρμα εισαγωγής αναζήτησης. Ένας χρήστης μπορεί να εισαγάγει έναν τίτλο ταινίας για να επιστρέψει ένα αποτέλεσμα ταινίας από το OMDb. Ο χρήστης μπορεί επίσης να αποθηκεύσει μια ταινία με βαθμολογία και σύντομο σχόλιο σε μια λίστα αγαπημένων.

Για να δείτε την τελική εφαρμογή, κάντε κλικ εδώ. Για να δείτε τον πηγαίο κώδικα, κάντε κλικ εδώ.

Όταν ένας χρήστης αναζητά μια ταινία στην αρχική σελίδα, μοιάζει με αυτό:

Για λόγους απλότητας, θα εστιάσουμε μόνο στο σχεδιασμό των βασικών χαρακτηριστικών της εφαρμογής σε αυτό το άρθρο. Μπορείτε επίσης να μεταβείτε στο Μέρος II: Reduxτης σειράς.

Δομή δεδομένων

Ο καθορισμός κατάλληλων δομών δεδομένων πρέπει να είναι μία από τις πιο σημαντικές πτυχές του σχεδιασμού μιας εφαρμογής. Αυτό πρέπει να έρθει ως το πρώτο βήμα, καθώς καθορίζει όχι μόνο πώς θα πρέπει να αποδώσει τα στοιχεία το frontend, αλλά και πώς ο διακομιστής API πρέπει να επιστρέψει τις απαντήσεις JSON

Για αυτήν την εφαρμογή, θα χρειαστούμε δύο βασικά στοιχεία για να αποδώσουμε σωστά τη διεπαφή χρήστη μας: ένα αποτέλεσμα μιας ταινίας και μια λίστα αγαπημένων ταινιών .

Αντικείμενο αποτελέσματος ταινίας

Ένα αποτέλεσμα μιας ταινίας θα περιέχει πληροφορίες όπως τον τίτλο, το έτος, την περιγραφή και την εικόνα της αφίσας. Με αυτό, πρέπει να ορίσουμε ένα αντικείμενο που μπορεί να αποθηκεύσει αυτά τα χαρακτηριστικά:

{ "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 για την εικόνα της αφίσας που θα εμφανίζεται στα αποτελέσματα. Εάν δεν υπάρχει διαθέσιμη αφίσα για αυτήν την ταινία, θα είναι "Δ / Υ", την οποία θα εμφανίσουμε ένα σύμβολο κράτησης θέσης. Χρειαζόμαστε επίσης ένα 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.

Σημείωση: η πολυπλοκότητα του χρόνου εκτέλεσης πιθανότατα δεν θα έχει σημασία στις περισσότερες περιπτώσεις, δεδομένου ότι τα σύνολα δεδομένων είναι συνήθως μικρά από την πλευρά του πελάτη. Θα κάνουμε επίσης λειτουργίες τεμαχισμού και αντιγραφής (επίσης O (N)) στο Redux ούτως ή άλλως. Αλλά ως μηχανικός, είναι καλό να γνωρίζουμε τις πιθανές βελτιστοποιήσεις που μπορούμε να κάνουμε.

Συστατικά

Τα συστατικά βρίσκονται στην καρδιά του React. Θα πρέπει να προσδιορίσουμε ποια θα αλληλεπιδράσουν με το κατάστημα Redux και ποια είναι μόνο για παρουσίαση. Μπορούμε επίσης να επαναχρησιμοποιήσουμε ορισμένα από τα στοιχεία παρουσίασης. Η ιεραρχία συστατικών μας θα μοιάζει με αυτό:

Κύρια σελίδα

Ορίζουμε το στοιχείο εφαρμογής μας στο ανώτερο επίπεδο. Όταν επισκέπτεται τη διαδρομή ρίζας, πρέπει να αποδώσει το SearchContainer . Πρέπει επίσης να εμφανίζει μηνύματα flash στον χρήστη και να χειρίζεται τη δρομολόγηση από την πλευρά του πελάτη.

Το SearchContainer θα ανακτήσει το αποτέλεσμα της ταινίας από το κατάστημα Redux, παρέχοντας πληροφορίες ως στηρίγματα στο 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

Είμαι μηχανικός λογισμικού που βρίσκεται στη Νέα Υόρκη και συν-δημιουργός του SpaceCraft. Έχω εμπειρία στο σχεδιασμό εφαρμογών μίας σελίδας, στο συγχρονισμό κατάστασης μεταξύ πολλών πελατών και στην ανάπτυξη επεκτάσιμων εφαρμογών με το Docker.

Αναζητώ την επόμενη ευκαιρία πλήρους απασχόλησης! Επικοινωνήστε μαζί σας αν νομίζετε ότι θα ταιριάξω στην ομάδα σας.