[JS] Jak pobrać dane z wielu źródeł jednocześnie do store w ReduxJS?
Pracując nad projektem JS, który wykorzystuje bibliotekę ReactJS może zajść potrzeba zbudowania architektury aplikacji zapewniającej dostęp do danych na serwerze. W przypadku, gdy zależy nam na rozdzieleniu logiki biznesowej od widoków aplikacji dobrym pomysłem może być wykorzystanie biblioteki ReduxJS, która będzie odpowiedzialna za zarządzanie stanem danych w aplikacji.
W tym wpisie nie będę omawiał podstaw ReduxJS lecz skupię się na problemie, który ostatnio napotkałem. Chodzi o pobieranie danych z wielu źródeł i aktualizacja widoku po pobraniu wszystkich danych ze wszystkich źródeł.
Jak wygląda problem?
Pracując nad projektem LeagueManager musiałem rozwiązać problem pobrania danych z 3-ch endpointów, które zawierały informacje o kolejno: zawodnikach, drużynach i pozycjach. Dopiero posiadając pełny zestaw informacji chcę wyrenderować widok z listą zawodników. ReduxJS zapewnia obsługę akcji synchronicznie. Co to oznacza? Jedna akcja może wywołać jedno zdarzenie, czyli zwrócenie danych ze store'a. Akcje są budowane jako obiekty a reducery są odpowiedzialne za zwracanie odpowiedniej kolekcji danych jako nowego stanu aplikacji. Można się zastanowić, gdzie tu jest miejsce aby wykonać asynchroniczne zapytanie do serwera po dane? Według założeń, takiego miejsca nie ma ani w akcji ani w reducerze. Store w aplikacji powinien mieć na starcie wszystkie potrzebne dane.
Jeden ze sposobów na rozwiązanie problemu
Jest wiele różnych sposobów na rozwiązanie tego problemu. Między innymi wykorzystanie redux-saga
, redux-promise
czy redux-logic
.
W celu rozwiązania swojego problemu wykorzystałem redux-thunk
. Jest to tzw. middleware czyli zestaw narzędzi/oprogramowanie, które ma być pośrednikiem między akcjami a reducerami. Dzięki niemu, będziemy w stanie wykonać dodatkowe zaplanowane przez nas czynności zanim popchniemy akcję dalej do reducera.
Korzystając z redux-thunk
pojawia się termin actionCreator. Jest to pewnego rodzaju hybryda akcji, która zwraca funkcję zamiast obiektu z danymi o akcji. Takie podejście pozwala nam na wpięcie kodu odpowiedzialnego za pobranie danych z serwera.
Struktura kodu
Na początek zajmijmy się strukturą aplikacji. Dla uproszczenia przyjmijmy, że struktura katalogów wygląda następująco:
W Twoim przypadku może być ona zupełnie inna, zależnie od potrzeb.
Akcje (actions)
Standardowo, akcje są ładunkami danych w postaci obiektów JS, które są przesyłane z aplikacji do store'a. Akcje są jedynym źródłem danych dla magazynu danych. Każda akcja musi zawierać type
. Przykładowa akcja może wyglądać następująco:
1 2 3 4 | { type: 'GET_PLAYERS', playerIds: [65, 21, 129] } |
Gdybyśmy chcieli to obudować standardową akcję obudować w funkcję, to należy zwrócić uwagę na to, aby funkcja zwracała obiekt, np.:
1 2 3 4 5 6 7 | export const getPLayers = (limit = 100, page = 1) => { return { type: CREATE_COMPETITION, limit, page }; } |
Dzięki temu, możemy wywoływać funkcję getPlayers()
zamiast ręcznie wpisywać całą strukturę obiektu akcji w wielu miejscach.
W przypadku, gdy korzystamy z redux-thunk
to akcje są funkcjami. Dzięki czemu, za pomocą akcji jesteśmy w stanie pobrać dane z zewnętrznego źródła danych lub możemy dokonać jakiejś innej wymaganej przez nas transformacji danych.
W takiej sytuacji, gdy akcja jest funkcją to zaczynamy mówić o tworze o nazwie actionCreator. W naszym przypadku, actionCreator wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | // actions/players.js // importujemy kod odpowiadający za dostęp do danych w WordPress import WPAPI from '../tools/wpapi'; // importujemy typy akcji z akcji dotyczących pozycji import { GET_POSITIONS_FETCH, GET_POSITIONS_SUCCESS } from './positions'; // importujemy typy akcji z akcji dotyczących drużyn import { GET_TEAMS_FETCH, GET_TEAMS_SUCCESS } from './teams'; // definiujemy typy akcji dla zawodników export const GET_PLAYERS_FETCH = 'GET_PLAYERS_FETCH'; export const GET_PLAYERS_ERROR = 'GET_PLAYERS_ERROR'; export const GET_PLAYERS_SUCCESS = 'GET_PLAYERS_SUCCESS'; // definiujemy actionsCreator o nazwie fetchPlayers; // jest on odpowiedzialny za pobranie danych o zawodnikach z serwera; export const fetchPlayers = (limit = 100, page = 1) => (dispatch, getState) => { // uruchamiamy akcje za pomocą funkcji dispatch // dzięki nim informujemy, że dane dotyczące zawodników, pozycji i drużyn // są własnie pobierane dispatch({type: GET_PLAYERS_FETCH}); dispatch({type: GET_TEAMS_FETCH}); dispatch({type: GET_POSITIONS_FETCH}); // zwracamy Promise, który pobiera dane o zawodnikach, pozycjach i drużynach return Promise.all([ WPAPI .player() .perPage(limit) .page(page), WPAPI .position() .perPage(limit), WPAPI .team() .perPage(limit) ]) // informujemy co robimy z danymi po pobraniu danych // w naszym przypadku, uruchamiamy kolejne akcje i przekazujemy dane .then(([players, positions, teams]) => { dispatch({ type: GET_PLAYERS_SUCCESS, items: players }); dispatch({ type: GET_POSITIONS_SUCCESS, items: positions }); dispatch({ type: GET_TEAMS_SUCCESS, items: teams }); }) // gdyby wystąpił błąd to wywołujemy akcję błędu .catch(error => dispatch({ type: GET_PLAYERS_ERROR, error })); }; |
W powyższym kodzie najważniejsze na co musisz zwrócić uwagę to zapis: export const fetchPlayers = (limit = 100, page = 1) => (dispatch, getState) => {
. To co w tamtej linijce widzimy, to funkcja zwracająca funkcję. Dzięki temu, najpierw przekazujemy parametry jak do standardowej akcji i zwracamy funkcję wykonującą dodatkowe akcje, która posiada dostęp do funkcji wykonującej akcje - dispatch
, oraz do funkcji zwracającej stan store'a - getState()
. Zwrócona funkcja jest uruchamiana przez middleware, który zostanie zdefiniowany w pliku store.js
, gdzie również jest inicjalizowany store.
Reducer
Następnym krokiem jest zdefiniowanie reducera. Pokrótce, o ile akcje informują, że coś się stało, to o tyle reducer jest odpowiedzialny za zmianę stanu aplikacji w odpowiedzi na akcję.
W przyjętym rozwiązaniu reducer odpowiada na 3 zdefiniowane akcje:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | // reducers/players.js // importujemy typy akcji z pliku zawierającego definicje akcji zawodników import { GET_PLAYERS_FETCH, GET_PLAYERS_ERROR, GET_PLAYERS_SUCCESS } from '../actions/players'; // definiujemy reducer, którego zadaniem będzie reakcja na predefiniowane typy akcji export default const players = (state = {}, action) => { if (action.type === GET_PLAYERS_SUCCESS) { return { isLoading: false, items: action.items }; } else if (action.type === GET_PLAYERS_FETCH) { return { isLoading: true, items: state.items }; } else if (action.type === GET_PLAYERS_ERROR) { console.log('[ERROR]', action); return { isLoading: false, items: state.items }; } return state; } |
Funkcja będąca reducerem przyjmuje dwa parametry:
state
- aktualny stan aplikacji,action
- obiekt akcji.
Jako odpowiedź zwraca odpowiednio przygotowany obiekt będący nowym stanem aplikacji. Obiekt zawiera dwie własności:
isLoading
- flaga, które informuje o tym, że dane dotyczące zawodników są właśnie ładowane,items
- tablica z danymi o zawodnikach.
Posiadając dane dotyczące obecnego stanu aplikacji oraz dane dotyczące akcji wraz informacjami jakie są przesyłane przez tą akcję, to jesteśmy w stanie dostarczyć odpowiedni zestaw danych do widoków aplikacji, które wywołały daną akcję.
Store
Posiadając zdefiniowane akcje i reducery możemy zająć się inicjalizacją store'a. Kod odpowiedzialny za inicjalizację magazynu danych wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | // store.js // importujemy funkcje createStore i applyMiddleware z ReduxJS import { createStore, applyMiddleware } from 'redux'; // importujemy syncHistoryWithStore z React Router Redux; // dzięki temu będziemy w stanie synchronizować stan aplikacji z historią przeglądarki; // dodatkowa, opcjonalna funkcjonalność import { syncHistoryWithStore } from 'react-router-redux'; // importujemy implementację historii przeglądarki w standardzie Reactowym import { browserHistory } from 'react-router'; // importujemy bibliotekę, która zapewnia obsługę akcji jako funkcji import thunk from 'redux-thunk'; // importujemy listę reducerów import rootReducer from './reducers/index'; // domyślny, startowy stan części store'a const defaultState = { isLoading: true, items: [] }; // domyślny stan store'a const defaultStore = { teams: Object.assign({}, defaultState), players: Object.assign({}, defaultState), positions: Object.assign({}, defaultState), }; // definujemy middleware, który będzie dołączony jako pośrednik pomiędzy widokami a reducerami const middlewares = [thunk]; // tworzymy store który implementuje middleware i domyślny stan magazynu const store = createStore(rootReducer, defaultStore, applyMiddleware(...middlewares)); // spinamy historię przeglądarki ze stanem store'a export const history = syncHistoryWithStore(browserHistory, store); export default store; |
W tym miejscu następuje bardzo ważna rzecz. Podczas tworzenia nowej instancji store'a/magazynu podpinamy middleware jakim jest redux-thunk
. Ponadto, definiujemy stan domyślny każdej z własności magazynu: teams
, players
i positions
. Gdybyśmy tego potrzebowali, to ilość middleware'ów, które możemy zaimplementować do naszego magazynu jest w zasadzie nieograczona. Wystarczy dodać kolejną pozycję w zmiennej middles
.
rootReducer czyli reducer zbiorczy
Czym jest rootReducer? Jest to zestawienie wszystkich reducerów złączonych w jeden obiekt. Dzięki czemu, bezproblemowo możemy wszystkie zdefiniowane przez nas reducery połączyć ze storem. W tym przykładzie, dla uproszczenia, jest wykorzystywany tylko jeden reducer, ale zazwyczaj jest ich o wiele więcej.
Przykładowa implementacja rootReducera wygląda następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 | // reducers/index.js import { combineReducers } from 'redux'; import { routerReducer } from 'react-router-redux'; import players from './players'; const rootReducer = combineReducers({ players, routing: routerReducer }); export default rootReducer; |
W samym kodzie nie dzieje się wiele. Importujemy funkcję combineReducers()
z ReduxJS oraz obiekt routerReducer
z react-router-redux
. Ponadto, importujemy wszystkie reducery, które chcemy wykorzystać w naszym magazynie. W kodzie powyżej jest to reducer o nazwie players
.
Warto pamiętać o tym, aby do listy złączonych reducerów dołączyć reducer odpowiedzialny za zarządzanie stanem magazynu zależności od adresu w przeglądarce.
Widok korzystający z akcji
Posiadając tak przygotowane akcje, reducery i store. Jesteśmy w stanie przystąpić do korzystania z nich w wybranych przez nas widokach. Przykładowy widok wyświetlający listę zawodników może wyglądać następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | import React, { Component } from 'react'; // importujemy funkcję odpowiedzialną za złączenie wielu actionCreatorów w jeden obiekt import { bindActionCreators } from 'redux'; // importujemy funkcję pozwalającą na połączenie widoku ze storem import { connect } from 'react-redux'; // importujemy akcje import * as playerActionCreators from '../actions/players'; // importujemy przykładowy widok odpowiedzialny za wyświetlenie zawodnika na liście import Player from './player'; class PlayersList extends Component { // w momencie, gdy wiemy że widok zostanie wyrenderowany // wywołujemy funkcję odpowiedzialną za pobranie listy zawodników z serwera componentWillMount() { this.getPlayersList(); } // akcje, które zdefiniowaliśmy w pliku action/players.js // będą dostępne w this.props jako funkcje getPlayersList() { this.props.fetchPlayers(); } // renderujemy widok pojedynczego zwodnika renderPlayer(player) { return <Player key={player.id} data={player} />; } // renderujemy widok listy render() { const players = this.props.players; return ( <div className="player-list"> {players.isLoading ? 'Loading players' : players.items.map(this.renderPlayer)} </div> ); } } // funkcja, której zadaniem będzie przekazanie metody dispatch do propsów, // co skutkuje tym, że akcje mają dostęp do funkcji dispatch const mapDispatchToProps = (dispatch) => bindActionCreators(Object.assign({}, playerActionCreators), dispatch); // mapujemy stan store'a do propsów const mapStateToProps = (state) => { return { players: state.players, }; }; // dokonujemy połączenia naszego widoku ze storem export default connect(mapStateToProps, mapDispatchToProps)(PlayersList); |
Zaimplementowany widok jest prosty. Dzieje się w nim kilka rzeczy na które warto zwrócić uwagę.
Po pierwsze, definiujemy dwie funkcje: mapDispatchToProps
oraz mapStateToProps
. Za pomocą pierwszej z nich przekazujemy dostęp do funkcji dispatch()
akcjom. Z kolei za pomocą drugiej funkcji mapujemy propsy widoku ze stanem store'a. Dzięki temu, widok będzie automatycznie odświeżany po zmianie wartości przechowywanych w storze pod kluczem players
.
Po drugie, definiujemy połączenie widoku ze storem za pomocą funkcji connect()
przekazując funkcje mapStateToProps
oraz mapDispatchToProps
, a następnie wywołując funkcję przekazującą widok do przyłączenia do store'a. Jest to przykład użycia funkcji wyższego rzędu.
Ostatnią rzeczą na którą warto zwrócić uwagę to metoda getPlayersList
wewnątrz widoku listy zawodników. Wywołuje ona akcję pochodzącą z pliku ../actions/player.js
. Akcje zostały zmapowane jako propsy widoku za pomocą funkcji mapDispatchToProps
. Tym samym, jesteśmy w stanie sami decydować o tym, kiedy dane potrzebne dla widoku zostaną pobrane z serwera.
Podsumowanie
Implementując zaproponowane przeze mnie rozwiązanie będziesz w stanie pobierać dane z zewnątrz na żądanie. Zawsze wtedy, kiedy widok tego oczekuje. Zastosowanie redux-thunk
jest jednym z proponowanych rozwiązań problemu obsługi asynchronicznych zapytań o dane.
Zaprezentowany przeze mnie przykład nie jest skomplikowany. Na początku może się wydawać, że jest dużo kodu do napisania (4 pliki) zanim zaczniemy korzystać z przygotowanych przez nas akcji. Lecz jest to gra warta świeczki. Dzięki temu mamy ładnie ustrukturyzowany kod i możemy w dogodny sposób odnaleźć się w kodzie naszej aplikacji.
Mam nadzieję, że udało mi się w sposób wyczerpujący omówić rozwiązanie problemu. Jeśli znasz inne sposoby na rozwiązanie tego samego problemu, to nie wahaj się zaprezentować je w komentarzu do tego wpisu. Zapraszam do komentowania!