chevron-left chevron-right

[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:

redux-fetch-multiple-sources-folder-structure

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:

  1. state - aktualny stan aplikacji,
  2. 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!