W ostatnim, dosyć długim wpisie pokazałem jak skonfigurować Server Side Rendering w React w oparciu o framework ExpressJS po stronie serwera. To jednak nie wszystko - podstawowy zestaw narzędzi używanych w większości aplikacji React, zawiera też przecież bardzo często bibliotekę Redux… Dlatego też dziś, rozszerzę przedstawiony tydzień temu przykład o wsparcie dla tej biblioteki. Jak sam się przekonasz, nie jest to nic trudnego!

P.S. Zakładam, że samego Reduxa już znasz, nie będę więc tutaj poświęcał czasu na wyjaśnianie co, po co, jak i dlaczego. Jeśli nie znasz tej tematyki to zapraszam najpierw do zapoznania się z moim wpisem na ten temat podstaw Redux.

Niezbędne pakiety

Zanim dodamy obsługę Reduxa do naszej aplikacji, należy doinstalować do niej dwa pakiety. Tradycyjnie używam do tego Yarn:

yarn add redux react-redux

Pakiet redux zawiera po prostu bibliotekę Redux, a react-redux pozwali nam na łatwe spięcie jej z naszą aplikacją React.

Reducer oraz zmiany w App.js

Teraz przyszedł czas na dodanie do naszej aplikacji reducera. W tym celu dodałem katalog reducers do katalogu src i umieściłem w nim plik index.js. Oto zawartość tego pliku:

export default function reducer(state, action) {
  switch (action.type) {
    case 'CHANGE_TEXT':
      return { ...state, initialText: 'changed in the browser!' };
    default:
      return { ...state };
  }
}

Powyższy reducer obsługuje tylko jedną akcję: CHANGE_TEXT. Zmienia ona wartość właściwości initialText stanu. Akcję tę podpinamy do guzika, który znajduje się w komponencie App:

import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';

class App extends React.Component {
  static propTypes = {
    initialText: PropTypes.string.isRequired,
    changeText: PropTypes.func.isRequired
  }

  onButtonClick(event) {
    event.preventDefault();

    this.props.changeText();
  }

  render() {
    return (
      <div>
        <p>{this.props.initialText}</p>
        <button onClick={this.onButtonClick.bind(this)}>change text!</button>
      </div>
    );
  }
}

const mapStateToProps = (state) => {
  return { ...state };
};

const mapDispatchToProps = (dispatch) => {
  return {
    changeText: () => dispatch({ type: 'CHANGE_TEXT' })
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

Z komponentu App zniknął wewnętrzny stan komponentu - to dlatego, że teraz stan ten przechowywać będziemy globalnie, w store Reduxa. Dzięki funkcji connect zaimportowanej z pakietu react-redux możliwe jest wstrzyknięcie wartości stanu (funkcja mapStateToProps) oraz metod wywołujących akcje (funkcja mapDispatchToProps) do obiektu props komponentu App.

Konfiguracja po stronie serwera

Przejdźmy teraz do konfiguracji store Reduxa po stronie serwera. Spójrz na odpowiednio zmodyfikowany przykład pliku server.js z poprzedniego wpisu:

import express from 'express';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducers from './reducers';

import Html from './components/Html';
import App from './components/App';

const app = express();

app.use(express.static(path.join(__dirname)));

app.get('*', async (req, res) => {
  const scripts = ['vendor.js', 'client.js'];

  const initialState = { initialText: "rendered on the server" };

  const store = createStore(reducers, initialState);

  const appMarkup = ReactDOMServer.renderToString(
    <Provider store={store}>
      <App />
    </Provider>
  );
  const html = ReactDOMServer.renderToStaticMarkup(
    <Html children={appMarkup}
          scripts={scripts}
          initialState={initialState}
    />
  );

  res.send(`<!doctype html>${html}`);
});

app.listen(3000, () => console.log('Listening on localhost:3000'));

Jak widzisz, tutaj nie zmieniło się zbyt wiele: dodany został import funkcji createStore z pakietu redux, komponentu Provider z pakietu react-redux oraz zdefiniowanego wcześniej reducera.

Następnie dodane zostało wywołanie funkcji createStore - jako parametry przekazuję oczywiście reducer oraz stan początkowy. Wynik działania tej funkcji przekazuję jako atrybut do komponentu Provider, którym “owijam” komponent App. Dzięki temu zadziała funkcja connect, którą dodaliśmy wcześniej do tego komponentu. Z tego samego powodu, nie musimy już przekazywać stanu początkowego jako atrybut komponentu App (wstrzyknięciem odpowiednich wartości zajmie się wspomniana funkcja connect).

Zwróć również uwagę, że stan początkowy nadal trafia do komponentu Html w celu zapisania go w zmiennej window. Za chwilę wykorzystamy go po stronie klienta.

Konfiguracja po stronie klienta

Na koniec zostały nam tylko zmiany w konfiguracji w pliku client.js. Oto one:

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducers from './reducers';
import App from './components/App';

const store = createStore(reducers, { ...window.APP_STATE });

ReactDOM.hydrate(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app'));

Tutaj również importujemy te same pakiety co w przypadku pliku server.js. Zwróć jednak uwagę na wywołanie funkcji createStore - tym razem przekazujemy do niej stan początkowy, który pobieramy z obiektu window. Podobnie więc jak w przykładzie z zeszłego tygodnia, do przekazania stanu pomiędzy środowiskami wykorzystujemy skrypt zdefiniowany w komponencie Html.

Po tym, jak na bazie stanu początkowego przekazanego z serwera utworzyliśmy obiekt store, możemy go przekazać poprzez atrybut do komponentu Provider. Dzięki temu komponent App wyrenderowany po stronie klienta, również będzie miał dostęp do stanu Reduxa. Tutaj także nie ma już potrzeby przekazywania stanu początkowego do komponentu (wszystkim zajmie się funkcja connect).

Redux w SSR - podsumowanie

To tyle na dziś! Wyszło znacznie krócej niż ostatnio, a to dlatego, że konfiguracja Reduxa w SSR opiera się na wykorzystaniu tych samych mechanizmów. Tutaj również należy przekazywać początkowy stan, tym razem całej aplikacji, pomiędzy serwerem a klientem, i robi się to w ten sam sposób: za pomocą przypisania stanu do zmiennej window.

Nie jest to jednak koniec serii na temat SSR w React! W kolejnym wpisie zobaczymy, jak do tworzonej przez nas aplikacji uniwersalnej dodać routing. Do tego celu wykorzystamy najpopularniejszą bibliotekę routingu dla Reacta, a więc react-router. Zapraszam!

Uwaga! Kod przedstawionego dziś przykładu jest oczywiście dostępny w serwisie GitHub - kliknij tutaj aby zobaczyć repozytorium.


P.S. Ten wpis jest częścią serii wpisów na temat Server Side Renderingu w React! Poniżej lista wszystkich wpisów tej serii: