Powiązanie React, Redux i Router

Router jest bardzo wygodnym rozwiązaniem w tworzeniu nawigacji na stronie. Jednak jego użyciem jest związane są pewne problemy - opisane poniżej. Do ich zaprezentowania stwórzmy prostą aplikację z magazynem Redux przechowującym stan licznika i dostarczającym dwie operacje: zwiększenia licznika i jego ustawienia:

function stanPoczatkowy() {
  return {
    licznik : 0
  }
}

const ACTION_USTAW = 'ACTION_USTAW';
const ACTION_ZWIEKSZ = 'ACTION_ZWIEKSZ';

export function zwieksz() {
  return { type: ACTION_ZWIEKSZ }
}

export function ustaw(n) {
  return {
    type: ACTION_USTAW,
    licznik : n
  }
}

const naszReducer = (state, action) => {
  if (action.type === ACTION_ZWIEKSZ) {
    let nowyLicznik=state.licznik+1;
    return( {licznik:nowyLicznik} );
  } else if (action.type === ACTION_USTAW) {
    let nowyLicznik=Number(action.licznik);
    return( {licznik:nowyLicznik} );
  } else  return state;
}

Do jego przetestowania wystarczy komponent:

class Licznik extends Component {
  render = () => (
      <button onClick={this.props.zwieksz}>
        Licznik = { this.props.reduxStore.licznik }
      </button>
    );
}

Wpiszemy w naszej aplikacji <Licznik /> i możemy testować zwiększanie poprzez klikanie w przycisk. Załóżmy jednak, że chcemy z menu ustawiać ten przycisk od razu na 5. Dodamy do naszego komponentu własność N. Nie jest ona wyświetlana, ale powinna zmienić stan. Można to zrobić w funkcji componentDidMount (cykl życia):

  componentDidMount = () => {
    if (this.props.N) this.props.ustaw(this.props.N);
  }

Tworzymy w naszej aplikacji Router. Ponieważ nie możemy podać w nim komponentu z parametrami (własnościami) - tworzymy komponent pośredniczący Licznik5:

const Licznik5 = (props) => (<Licznik N={5} />)

class App extends Component {
  render() {
    return(
    <Router>
      <div>
        [<Link  to='/'>Licznik</Link>]
        [<Link  to='/licznik5'>Licznik5</Link>]
        <div className="container">
          <Route exact path="/" component={Licznik} />
          <Route exact path="/licznik5" component={Licznik5} />
        </div>
      </div>
    </Router>
  )}
}

Niezależnie od tego, czy wpiszemy w przeglądarce adres /licznik5 (http://localhost:3000/licznik5), czy też klikniemy w link "Licznik 5" - uzyskamy ten sam efekt - wyświetlony komponen Licznik z wartością początkową licznika równą 5.

Gdy zamiast własności N użyjemy paremetru routera, możemy ustawiać licznik z URL! Dodajmy zatem elementy routingu:

 [<Link  to='/licznik/5'>Licznik/5</Link>]
 <Route exact path="/licznik/:N" component={Licznik} />

W naszym komponencie aby uwzględnić obie możliwości (własność i parametr routera) zmieniamy nieco componentDidMount:

  componentDidMount = () => {
    if (this.props.N) this.props.ustaw(this.props.N);
    if (this.props.match)
      this.props.ustaw(this.props.match.params.N);
  }

Możemy wybrać z menu którąś z opcji, lub wstawić do URL wartość licznika (na przykład: http://localhost:3000/licznik/10.

Problem przenoszenia parametrów routera

Co jednak się stanie jeśli w powyższym przykładzie chcemy komponent Licznik umieścić w innym komponencie, ale przekazać parametr z routera?

Na przykład tak:

const LicznikN = (props) => (<div>Licznik z opisem: <Licznik /></div>)

Zmieniamy odpowiednią trasę:

<Route exact path="/licznik/:N" component={LicznikN} />

Jednak po wybraniu licznik/10 wcale nie uzyskamy parametru startowego. Wartość props.match nie jest przekazywana do potomków. Możemy jednak tą własność "wstrzyknąć" przy pomocy funkcji withRoute (z pakietu react-router-dom):

Licznik=withRouter(Licznik)

Funkcja withRoute działa podobnie jak connect z Reduxa - wstrzykiwuje własności do props (tu: match z routingu). Te obie fukcje możemy łaczyć razem:

Licznik = connect(
  mapStateToProps,
  mapDispatchToProps
)(withRouter(Licznik));

Teraz licznik będzie działał zgodnie z oczekiwaniami.

Zobacz przykład [037]

Linkowanie, Zarządzanie historią

Funkcja withRouter przepisuje dane z kontekstu (context) do props (podobnie jak connect dla Redux). Gdy używamy Routera - wśród tych danych jest nie tylko match, ale także location (wybrany link) i history(cała ścieżka). Pod warunkiem, że pakiet history zostanie zainstalowany:

yarn add history

Komponent history pozwala na przykład na wyświetlenie wybranej ścieżki:

import { withRouter } from 'react-router-dom';

let TestPage = (props) => {
//  console.log(props.history.location.pathname);
  return (
    <div className="container">
 {props.history.location.pathname}
    </div>
  );
}

TestPage = withRouter(TestPage);

Więcej informacji:

Problem synchronizacji

W naszym przykładzie z licznikiem, możemy komponent Licznik wyświetlić na kilka sposobów - samodzielnie albo wewnątrz innego komponentu. Można też podać stan początkowy z URL. Stan aplikacji (niekoniecznie tego komponentu) może się różnić w zależności od tego - którą drogą do niego dotarliśmy. A może chcemy, by wyglądało to identycznie - niezależnie od tego czy zwiększamy licznik klikając, czy podając wartość URL? Pomocnym w tym jest pakiet connected-react-router. Zapewnia on możliwość synchronizacji Routera z React i Redux. Jego instalacja nie jest banalna:

1) instalujemy history i connected-react-router :

yarn add history  connected-react-router

2) Dodajemy do store Redux'a elementy Middleware związane z obsługą history (moduł magazyn.js):

import { applyMiddleware, compose } from 'redux'
import { createBrowserHistory } from 'history'
import { connectRouter, routerMiddleware } from 'connected-react-router'

.....
.....

export const history = createBrowserHistory()
export const store = createStore(
  connectRouter(history)(naszReducer),
  stanPoczatkowy(),
  applyMiddleware(
     routerMiddleware(history),
  )
)

3) W funkcji ReactDOM.render (moduł index.js) dostarczamy nie tylko magazyn Reduxa (Provider) ale też obiet history (utworzony wyżej w magazyn.js):

import { store, history } from './magazyn';
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import App from './App';

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

Możemy wreszcie zacząć używać nowych możliwości. Na przykład history.push powoduje zmianę adresu URL:

class Licznik extends Component {
  componentDidMount = () => {
    if (this.props.N) this.props.ustaw(this.props.N);
    if (this.props.match) {
      if (this.props.match.params.N)
        this.props.ustaw(this.props.match.params.N);
    }
    this.props.history.push('/licznik/'+this.props.reduxStore.licznik)
  }
  klik = () => {
      let N = this.props.reduxStore.licznik+1;
      this.props.zwieksz();
      this.props.history.push('/licznik/'+N);
      // nie odwołuje się wprost do this.props.reduxStore.licznik
      // bo mógł się jeszcze nie zmienić
  }
  render = () => (
      <button onClick={this.klik}>
        Licznik = { this.props.reduxStore.licznik }
      </button>
    );
}

Jak widać - wprowadzono funkcję klik, która nie tylko powoduje zmiany store (zwieksz), ale też url (push). Zobacz przykład [038]