Redux

Wróćmy do naszego przykładu - gry w kółko i krzyżyk w React.

Chcemy powiązać elementy – na przykład zasygnalizować, gdy w naszej grze ktoś wygrał (ma 3 znaki w jednej linii).

Nie wnikając w szczegóły (nie będziemy się tym zajmować) – poza props i state w każdym obiekcie jest context który działa tak jak props, ale udostępnia dane dla potomków. Dzięki temu jest możliwe połączenie między obiektami. Instrukcja bind wspomniana wyżej (this.onClick = this.onClick.bind(this); ) umożliwia właśnie zmianę kontekstu przy wywołaniu onClick.

Redux wykorzystuj kontekst implementując pamięć „Storage”) - wspólną dla wszystkich obiektów.

Redux nie dostarcza gotowej pamięci (Storage), ale trzeba ją zdefiniować.

Definicja obejmuje:

  • store – stan całej aplikacji
  • reducer – wyliczający nowy stan
  • akcje (dispacher)– czyli funkcje wywołujące pożądane zmiany (reducer dla określonego parametru).

Do tego potrzebujemy trochę „magii”. Poniższa struktura może służyć jako szablon (szkielet) implementacji.

Ponieważ przykład jest stosunkowo prosty - pozostawiono szczegóły implementacyjne (całość: https://github.com/galicea/eduprog/tree/master/web/react/redux/xo).

1) Definiujemy identyfikatory akcji (src/types/index.js). Użycie stałych w miejsce nazw (łańcuchów znaków) chroni przed "literówkami":

export const ACTION_ZMIANA = 'ACTION_ZMIANA'

2) Pamięć (plik src/store.js):

import { createStore, } from 'redux';
import * as actionTypes from './types/index';

const StanAplikacji = {
  xo : [], // znak x / o
  kto : '',  // czyj ruch
  stanGry: ''// komunikat
};

function inicjujStanAplikacji() {
  return {
    xo: new Array(9).fill('?'),
    kto: 'x',
    stanGry: '* Kółko i Krzyżyk *'
  };
}

const reducer = (state, action) => {
    if (action.type === actionTypes.ACTION_ZMIANA) {
    let ix = action.y*3 + action.x;
    if (state.xo[ix] !== '?') return state;
    let nxo  = state.xo.slice(); // PŁYTKA KOPIA state.xo.
    nxo[ix] = state.kto;
    let nkto='x';
    let nstan = 'ruch: x';
    if (state.kto==='x') {
      nkto='o';
      nstan = 'ruch: o';
    }
    if (   ((nxo[0] !== '?') && (nxo[0]===nxo[1]) && (nxo[0]===nxo[2]))
         || ((nxo[3] !== '?') && (nxo[3]===nxo[4]) && (nxo[3]===nxo[5]))
         || ((nxo[6] !== '?') && (nxo[6]===nxo[7]) && (nxo[6]===nxo[8]))
         || ((nxo[0] !== '?') && (nxo[0]===nxo[3]) && (nxo[0]===nxo[6]))
         || ((nxo[1] !== '?') && (nxo[1]===nxo[4]) && (nxo[1]===nxo[7]))
         || ((nxo[2] !== '?') && (nxo[2]===nxo[5]) && (nxo[2]===nxo[8]))
         || ((nxo[0] !== '?') && (nxo[0]===nxo[4]) && (nxo[0]===nxo[8]))
         || ((nxo[2] !== '?') && (nxo[2]===nxo[4]) && (nxo[2]===nxo[6]))  ) { 
         nstan = 'KONIEC';}
    return {
     ...state, // spread operator https://redux.js.org/recipes/using-object-spread-operator
      xo : nxo,
      kto : nkto,
      stanGry : nstan
    };

  } else {
     return state;
  }
}

export {StanAplikacji};
export const store = createStore(
  reducer,
  inicjujStanAplikacji(),
  // dla debuggera window.__REDUX_DEVTOOLS_EXTENSION__ && 
  // window.__REDUX_DEVTOOLS_EXTENSION__()
);

Kluczowe znaczenie ma reducer, który na podstawie stanu i akcji wypracowuje nowy stan. Struktura action zawiera wcześniej zdefiniowany identyfikator i ewentualne parametry (zobacz poniżej). W większych aplikacjach w miejsce if stosuje się:

switch (action.type) {

case ACTION_<NAZWA_AKCJI>:

default: return state;

}

Pamięć (store) twozymy funkcją createStore z parametrem reducera i początkową wartością..

Fragment wyliczenia nowego stanu może wygladac następująco:

return Object.assign({ }, state, { <NOWY STAN> });

3) Definiujemy kreatory akcji (plik actions/index.js):

import * as actionTypes from '../types/index';

export function zmiana(x,y : string) {
  return {
    type: actionTypes.ACTION_ZMIANA,
    x,
    y
  };
}

4) W aplikacji (App.js) łączymy kontekst z pamięcią Reduxa:

import React  from 'react';
import './App.css';
import XO, {Wynik} from './xo.js';
import { connect } from 'react-redux';


class App extends React.Component {

    render() {
        return (
            <table className="plansza">
                <tbody>
                <tr>
                    <XO x={0} y={0} c={this.props.data.xo[0]} />
                    <XO x={1} y={0} c={this.props.data.xo[1]} />
                    <XO x={2} y={0} c={this.props.data.xo[2]} />
                </tr>
                <tr>
                    <XO x={0} y={1} c={this.props.data.xo[3]} />
                    <XO x={1} y={1} c={this.props.data.xo[4]} />
                    <XO x={2} y={1} c={this.props.data.xo[5]} />
                </tr>
                <tr>
                    <XO x={0} y={2} c={this.props.data.xo[6]} />
                    <XO x={1} y={2} c={this.props.data.xo[7]} />
                    <XO x={2} y={2} c={this.props.data.xo[8]} />
                </tr>
                <tr>
                    <td colSpan="3"><Wynik msg={this.props.data.stanGry}/></td>
                </tr>
                </tbody>
            </table> );
    }
}


const mapStateToProps = state => {
    return {
        data: state
    }
};


export default connect(
    mapStateToProps
)(App);

Najważniejszą zmianą jest wykonanie"wstrzyknięcia" pamięci do własności obiektów - przy pomocy connect:

import{connect}from'react-redux'; export default connect( ... )(App);

Ponieważ mapujemy stan (state) Redux'a na własność o nazwie "data" - możemy się odwoływać do wartości tego stanu poprzez this.props.data....

Jest to coś, co nazywa się "wstrzykiwaniem" funkcjonalności do obiektu.

5). Implementacja XO zmieni się tak, by wywołać zdefiniowaną akcję.

Podobnie jak w przypadku App dodajemy tu funkcjonalność poprzez connect. Ale tym razem odwołanie do akcji.
Funkcja connect jako argumenty przyjmuje dwie funkcje zwyczajowo nazywane mapStateToProps i mapDispatchToProps

mapStateToProps — jako argument przyjmuje cały stan i musi zwrócić propsy dla danego komponentu

mapDispatchToProps — jako argument przyjmuje funkcję lub obiekt z action creators

import React  from 'react';
import {connect} from "react-redux";
import {bindActionCreators} from "redux";
import * as Akcje from './actions';

class Wynik extends React.Component {
    render() {
        return <span>{this.props.msg}</span>
    }
}

export {Wynik};

class XO extends React.Component {
    render() {
      return <td onClick={ () =>  this.props.zmiana(this.props.x,this.props.y) }>{this.props.c}</td>
    }
}

const mapDispatchToProps = dispatch => bindActionCreators(
    Akcje,
    dispatch,
)

export default connect(
    null,
    mapDispatchToProps
)(XO);

W powyższym przykładzie należy dodatkowo zwrócić uwagę na użycie funkcji labda ( () => this.....). Taki zapis sprawia, że zmienna obiektowa this (jak i ewentualne inne zmienne obiektu) pochodzą z obiektu wywołującego. A więc this zawiera własności z XO. Przy innym zapisie, musielibyśmy użyć bind(this).

6) Ostatnią rzeczą, jaką musimy wykonać jest zmiana głównego renderowania (plik index.js) poprzez dodanie providera dostarczającego pamięć:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { store } from './store.js'
import { Provider } from 'react-redux'
import App from './App';

ReactDOM.render(
<Provider store={store}>
<App klasa="plansza"/>
</Provider>,
document.getElementById('root')
);

Przed ponownym uruchomieniem (yarn start) wykonujemy:

yarn add redux
yarn add react-redux