Reduxと状態管理

このドキュメントは作業中です。リリース前の情報であり、変更される可能性があります。

このページでは、Reduxを使用してアプリケーションの状態を管理するために必要な手順について説明します。

目次

一般な原則

Reduxは小さな状態管理コンテナで、ビューとは関係なく広く使われています。これは、アプリケーションロジック(アプリケーション状態)をビューレイヤーから分離し、そのストアをアプリケーション状態の真の単一のソースとして持つという考えを中心にしています。私たちはRedux docsの一部と、Lin Clark氏によるこの素晴らしいReduxの紹介漫画を読むことをお勧めします。

Reduxのすばらしい機能の1つは、タイムトラベルデバックを実行できることです。特に、このChrome拡張機能を使います。

いくつかの定義

Reduxを使って作業するとき、たくさんの言葉が多用されます。最初は混乱するかもしれません:

命名規則

アプリケーションコードを次のように配置することをお勧めします:

src
├── store.js
├── actions
│   └── counter.js
|   └── ...
├── reducers
│   └── counter.js
|   └── ...
└── components
    └── simple-counter.js
    └── my-app.js
    └── ...

ストアに要素を接続する

接続するとは

一般に、ストアデータに直接アクセスする必要があるものは、connected要素とみなす必要があります。 これには、状態を更新する( store.dispatchを呼び出す)か状態監視する(store.subscribeを呼び出す)の両方が含まれます。

ただし、要素がストアデータを監視する必要がある場合、接続された親要素からのデータバインディングを介してこのデータを受け取ることができます。 ショッピングカートの例について考えると、カートの内容はアプリケーションの状態の一部なので、カート自体をストアに接続する必要がありますが、接続する必要のあるカート内の各アイテムをレンダリングする再利用可能な要素は必要ありません(データバインディングを介してデータを受け取るだけなので)

これは非常にアプリケーション固有の決定なので、それを見始める1つの方法は、遅延ロードされた要素を接続してそこから1つ上または下に移動することです。それは下記のように見えるかもしれません: screen shot 2018-01-25 at 12 22 39 pm

この例では、 my-app my-view1だけが接続されています。 a-elementはアプリケーションレベルのコンポーネントではなく、再利用可能なコンポーネントであるため、アプリケーションのデータを更新する必要がある場合でも、 これのようなDOMイベントを介してこれを伝えます。

接続方法

実際のコードに従う場合、基本的なRedux カウンターサンプルpwa-starter-kitに含まれています。

ストアの作成

シンプルなストアを作成したい場合、それは遅延読み込みするreducerではないので、おそらくこのようなものが必要です:

export const store = createStore(
  reducer,
  compose(applyMiddleware(thunk))
);

redux-thunkミドルウェアが追加されているため、これはまだ最も基本的なストアではないことに注意してください。これにより、非同期アクションをディスパッチできます。これは中程度の複雑さのアプリケーションでは必須です。 しかし、ほとんどの場合、怠惰な遅延読み込みのルートになるでしょうし、reducerも遅延してロードする必要があります。そのため、初期化後にreducerを置き換えることができるストアが必要なので、 pwa-starter-kitではlazyReducerEnhancer redux-thunkを使います:

export const store = createStore(
  (state, action) => state,
  compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk))
);

lazyReducerEnhancerについては遅延読み込みにより詳細な説明があります。

ストアへの要素の接続

接続される要素は、コンストラクタで store.subscribeを呼び出す必要があり、リスナーからの変更通知によって直ちにその通知された更新部分に限ってプロパティを更新します(その要があれば)。pwa-helpersにはmixin(connect-mixin.js)が用意されており、そこですべての接続がされ、stateChangedメソッドを実装することにより利用できます。サンプルとしては:

import { LitElement, html } from '@polymer/lit-element/lit-element.js'
import { connect } from  '@polymer/pwa-helpers/connect-mixin.js';
import { store } from './store/store.js';

class MyElement extends connect(store)(LitElement) {
  static get is() { return 'my-element'; }

  static get properties() { return {
    clicks: { type: Number },
    value: { type: Number }
  }}

  render() {
    return html`...`;
  }

  // もしこのメソッドが実装されていないと
  // コンソールに警告が表示されます。
  stateChanged(state) {
    this.clicks = state.counter.clicks;
    this.value = state.counter.value;
  }
}

If you’re doing any expensive work in stateChanged, such as transforming the data from the Redux store (with something like Object.values(state.data.someArray)), consider moving that logic into the render() function (which is called only if the properties update), using a selector, or adding some form of dirty checking

stateChangedはストア更新時にエレメントの表示更新とは関係なく毎回呼び出されるので注意してください。 したがって、上記の例では、 stateChangedstate.counter.clicksstate.counter.valueが変わることなく複数回呼び出されています。Reduceストアから(Object.values(state.data.someArray)のようなもので)データを変換するなど、 stateChangedで高価な作業をしている場合は、そのロジックをrender()関数(プロパティが更新された場合のみ呼び出されます)に移動し、セレクタか何らかのダーティチェック(元データとの変更確認)を追加してください:

stateChanged(state) {
  if (this.clicks !== state.counter.clicks) {
    this.clicks = state.counter.clicks;
  }
}

ディスパッチアクション

要素がストアを更新するアクションをディスパッチする必要がある場合、アクションクリエータを呼び出す必要があります:

import { increment } from './store/actions/counter.js';

firstUpdated() {
  // カウンタの表示が更新されるたびに、
  // これらの値をストアに保存します
  this.addEventListener('counter-incremented', function() {
    store.dispatch(increment());
  });
}

アクションクリエータは、システムが何をしているのか、それが何をしているのかを伝えます。このアクションクリエータは、同期アクションを返すことができます:

export const increment = () => {
  return {
    type: INCREMENT
  };
};

非同期の場合は

export const increment = () => (dispatch, getState) => {
  // ここで何か処理
  const state = getState();
  const howMuch  = state.counter.doubleIncrement ? 2 : 1;
  dispatch({
      type: INCREMENT,
      howMuch,
    });
  }
};

もしくは他のアクションクリエータに結果をディスパッチ:

export const increment = () => (dispatch, getState) => {
  // ここで何か処理
  const state = getState();
  const howMuch = state.counter.doubleIncrement? 2 : 1;
  dispatch(incrementBy(howMuch));
};

ケーススタディをみる

このチュートリアルの目的は、標準的なReduxサンプルの2つを pwa-starter-kitテンプレートアプリケーションにどのように追加したのかを説明することによって、Reduxを始める方法を示します。

例1: カウンター

counterの例は非常に簡単です: counterカスタム要素(これは再利用可能であるものとして)を my-view2.jsにコピーします。 この例は非常に詳細で、変更する必要があるコードのすべての行を示しています。上位レベルの例が必要な場合は、例2を参照してください。要素、アクションクリエータ、アクション、reducer、ストア間の相互作用は、次のようになります: screen shot 2018-01-25 at 12 44 24 pm

counter.element.js

これはReduxストアに接続していない普通のエレメントです。Reduxストアに接続されていないので、clicksvalueの2つのプロパティとカウントアップとカウントダウンをする2つのボタンをもっています(clicksは増え続けます)。

my-view2.js

このエレメントはアプリケーションとしてのエレメント (再利用可能な要素とは対照的に)で、ストアに接続しています。これは、アプリケーションの状態を読み取り、更新できることを意味します。特に、counter-elementからのvalueとclicksプロパティです。 必要な機能のは:

<counter-element value="${props._value}" clicks="${props._clicks}"></counter-element>
<div class="circle">${props._clicks}</div>
import { connect } from '@polymer/pwa-helpers/connect-mixin.js';
class MyView2 extends connect(store)(LitElement) {
...
}
import counter from '../reducers/counter.js';
store.addReducers({
  counter
});
stateChanged(state) {
  this._clicks = state.counter.clicks;
  this._value = state.counter.value;
}
this.addEventListener('counter-incremented', function() {
  store.dispatch(increment());
})

例1: ショッピングカート

ショッピングカートの例はもう少し複雑です。メインビュー要素(my-view3.js)には、選択可能な製品のリストである<shop-products> と、ショッピングカートである<shop-cart>があります。リストから商品を選択してカートに追加することができます。各商品には在庫が限られており、在庫がなくなります。カート内でチェックアウトを実行することができます。これは、失敗する可能性があります(実際の生活では、クレジットカードの確認、サーバーエラーなどにより失敗する可能性があります)。これは次のようになります。 screen shot 2018-01-25 at 12 50 22 pm

my-view3.js

これは、商品のリスト、カート、および[チェックアウト]ボタンの両方を表示する接続された要素です。カートにアイテムがあるかどうか(チェックアウトボタンを表示するかどうかなど)に基づいて条件付きUIを表示する必要があるため、接続されているだけです。Checkoutボタンがカートに属していた場合、これは接続されていない要素となります。たとえば:

shop-products.js

この要素は、 getAllProductsアクションクリエータをディスパッチすることによってストアからプロダクトのリストを取得します。 ストアが更新されると(例えば、サービスからプロダクトを取得することによって)、 stateChangedメソッドが呼び出され、productsオブジェクトが生成されます。 最後に、このオブジェクトは製品のリストをレンダリングするために使用されます。

shop-cart.js

shop-productsと同様に、この要素もストアに接続され、productscartの両方の状態を監視します。 Reduxのルールの1つは、正しい情報元が1つだけあるべきであり、データを複製するべきではないということです。 この理由から、 productsは正しい情報元であり(利用可能な全てのアイテムを含んでいます)、cartはカートに追加されたインデックスと数を含んでいます。

ルーティング

私たちは非常にシンプルな(しかし、柔軟性のある)リデュースフレンドリーなルータを使用しています。ルータは、ウィンドウの場所を使用してストアに保管します。これは、 pwa-helpersパッケージから提供されたinstallRouterヘルパーメソッドを使用して行います:

import { installRouter } from '@polymer/pwa-helpers/router.js';
firstUpdated() {
  installRouter((location) => this._locationChanged(location));
}

パターン

アクションクリエイターにDOMイベントを接続する

すべての要素をストアに接続したくない場合は、接続していない要素は、ストア内の状態を更新を伝達する必要があります。

手動

これを手動で行うには、イベントを発生させます。 <child-element>が接続されてなく、プロパティfooを表示して変更した場合:

_onIncrement() {
  this.value++;
  this.dispatchEvent(new CustomEvent('counter-incremented');
}
<counter-element on-counter-incremented="${() => store.dispatch(increment())}"

JavaScriptでは

firstUpdated() {
  this.addEventListener('counter-incremented', function() {
    store.dispatch(increment());
  });
}

自動

あるいは、foo-changedプロパティー変更イベントをReduxアクションに自動的に変換するヘルパーを書くことができます。 これは <child-element>のプロパティが通知する(つまり notify:trueを持つ)必要があることに注意してください。 ここにサンプルがあります。

Reducers: スライスする

あなたのアプリをもっとモジュラー化するために、主要な状態オブジェクトをパーツ(”slices”)に分割し、より小さい”スライスレデューサ”を各パーツで動作させることができます(スライスレデューサについて詳しく読む). lazyReducerEnhancerを使うと、アプリケーションは必要に応じて遅延縮小を追加することができます(例えば、my-view2が処理するのにmy-view2jsだけをインポートするには、counterスライスレデューサを追加します。)

src/store.js:

export const store = createStore(
  (state, action) => state,
  compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk))
);

src/components/my-view2.js:

// この要素はReduxストアに接続する
import { store } from '../store.js';

// reducerを遅延読み込みする
import counter from '../reducers/counter.js';
store.addReducers({
  counter
});

重複状態を避ける

Reselectライブラリを使用して、状態に重複したデータを保存することは避けてください。 例えば、状態は項目のリストを含むことができ、その1つは選択された項目(例えば、URLに基​​づく)である場合です。選択したアイテムを別々に格納する代わりに、選択したアイテムを計算するセレクタを作成します:

import { createSelector } from 'reselect';

const locationSelector = state => state.location;
const itemsSelector = state => state.items;

const selectedItemSelector = createSelector(
  locationSelector,
  itemsSelector,
  (location, items) => items[location.pathname.slice(1)]
);

// 選択した項目を取得するには:
console.log(selectedItemSelector(state));

この例を見るには、カートの例を見てくださいカート個数セレクタ もしくは Redux-HNアイテムセレクタ。どちらの例でもセレクタはRedux側とビュー層の両方で使用されているため、実際にreducerで定義されています。

サードパーティのコンポーネントが状態を変更しないようにする方法

ほとんどのサードパーティコンポーネントは副作用のない方法で使用するようには作られておらず、Reduxストアに接続されていないため、ストアの更新を保証できません。例えば、 例えばpaper-inputvalueプロパティを持っています。内部的なアクション(つまり、あなたの入力、検証など)に基づいて更新されます。 このような要素がストアを更新しないようにするには:

ルーティング

Reduxを使用すると、基本的にはルーティングは自分で制御します。しかし、私たちはルーターヘルパーを提供しています。 私たちの提案は window.locationに基づいて位置状態を更新することです。 つまり、リンクがクリックされるたびに(またはユーザーが後方をナビゲートすると)、アクションが送出されて場所に基づいて状態が更新されます。これは、タイムトラベルのデバッグではうまくいきます。以前の状態にジャンプしても、URLバーや履歴スタックには影響しません。

ルータの設置と使用の例:

// ...
import { installRouter } from '@polymer/pwa-helpers/router.js';

class MyApp extends connect(store)(LitElement) {
    // ...
    firstUpdated() {
      installRouter((location) => this._locationChanged(location));

      // installRouterに渡される引数はコールバックです。あなたがアクションを
      // ディスパッチすること以外に他の仕事をしていない場合は、次のようなものを書くこともできます:
      // installRouter((location) => store.dispatch(navigate(location.pathname)));
    }

    _locationChanged(location) {
      // どのアクションクリエータをあなたが作成し、
      // どの部分に反映させるか
      store.dispatch(navigate(location.pathname));

      // 必要に応じてdrawerを閉じるなどします。
    }
  }
}

遅延読み込み

PRPLパターンの主な側面の1つは、必要に応じてアプリケーションのコンポーネントを遅延ロードすることです。これらの遅延ロードされた要素の1つがストアに接続されている場合は、その要素のreducerも遅延ロードできる必要があります。

あなたがこれを行うことができる方法はたくさんあります。 そのうちの1つをヘルパーとして実装しました。これはストアに追加することができます:

import lazyReducerEnhancer from '@polymer/pwa-helpers/lazy-reducer-enhancer.js';

// 遅延読み込みされない初期のreducer
import app from './reducers/app.js';

export const store = createStore(
  (state, action) => state,
  compose(lazyReducerEnhancer, applyMiddleware(thunk))
);

// reducerの初期化
store.addReducers({
  app
});

この例では、アプリケーションは起動し、 app reducersをインストールしますが、他のものはインストールしません。遅延ロードされた要素では、reducerをロードするために、 store.addReducersを呼び出すだけです。:

// この要素が遅延読み込みされる場合は、そのreducerも一緒に読み込みする必要があります
import { someReducer } from './store/reducers/one.js';
import { someOtherReducer } from './store/reducers/two.js';

// reducerを遅延読み込み
store.addReducers({someReducer, someOtherReducer});

class MyElement extends ... {

}

ストレージの状態を複製する

あなたのアプリがやりたいことの1つは、アプリの状態をストレージに保存することです (データベースもしくはlocalStorageなど。Reduxは、基本的には、状態をストレージにダンプする新しいreducerをインストールするだけで済むため、非常に便利です。)

これを行うには、最初にsaveStateloadStateという2つの関数を作成して、ストレージに読み書きします:

export const saveState = (state) => {
  let stringifiedState = JSON.stringify(state);
  localStorage.setItem(MY_KEY, stringifiedState);
}
export const loadState = () => {
  let json = localStorage.getItem(MY_KEY) || '{}';
  let state = JSON.parse(json);

  if (state) {
    return state;
  } else {
    return undefined;  // 初期値としてreducerが利用
  }
}

そして、 store.jsでは、基本的に loadState()の結果をストアのデフォルト状態として使用し、ストアが更新されるたびにsaveState()を呼び出します:

export const store = createStore(
  (state, action) => state,
  loadState(),  // ローカルストレージデータがある場合は、ロードします。
  compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk))
);

// このsubscriberは状態が更新されるたびにローカルストレージに書き込みます。
store.subscribe(() => {
  saveState(store.getState());
});

それでおしまい!プロジェクトのデモを見たい場合は、Flash-Cardsアプリが実装しているパターンを見てください。