Vuex + watch + subscribeActionで拡張性のある単方向データフローを設計する

フロントエンドで開発していると、時々特定のstateの変化をトリガーとして処理を行いたいときがあります。
例えばブログサービスで記事の編集を開始すると未保存状態ではページ遷移時にブラウザの標準ポップアップを表示するなど。こんなときにVue、Vuex、Nuxt.jsでどのように実装していくかの記事になります。

# 今回実装するもの
実装するのは以下のようなテキストを入力して保存を押すとサーバー側に保存（今回はモック）されるようなものです。
![スクリーンショット 2019-01-27 12.50.13.png](https://qiita-image-store.s3.amazonaws.com/0/308214/418e295e-c0de-4f95-8a64-ca913484bafa.png)

データのフローとしては以下のようになります
![qiita tmp.002.jpeg](https://qiita-image-store.s3.amazonaws.com/0/308214/b7a7ab8f-a389-32f5-f0ee-6d82d1fa5fcf.jpeg)

①全体を覆っているlayouts/default.vueのmount時にStoreのActions initをdispatchする
②initでサーバー側から取得したデータ（今回はモック）をstateにcommitし、最後にMutationsのchangeIsInitializedをcommitしてstateのisInitializedをtrueにする
③isInitializedがtrueになり、layouts/default.vue内でv-ifによって制御されていた子コンポーネントの部分が表示され、page/index.vueが表示される
④その後は各input要素のonChangeで該当input要素に紐づくstateを更新し、保存ボタンを押すことでサーバー側に保存し（今回はモック）、レスポンスをMutationsのcommitによりstateに格納する。

ページ構造はNuxt.jsに従って以下のようになります。

```
nuxt
├── layouts
│   └── default.vue
├── pages
│   └── index.vue
└── store
    └── index.js
```

コードは以下のようになります。

```vuejs:layouts/default.vue
<template>
  <div><nuxt v-if="$store.state.isInitialized" /></div>
</template>
<script>
export default {
  mounted() {
    const { $store } = this;
    $store.dispatch('init');
  },
};
</script>

```

```vuejs:pages/index.vue
<template>
  <section>
    <div >
      <div>Watcher And Subscriber</div>
      <div>
        <label>rootHoge</label>
        <input
          :value="$store.state.content.hoge"
          @change="e => changeHoge({ value: e.target.value })"
          type="text"
        />
      </div>
      <div>
        <label>rootFuga</label>
        <input
          :value="$store.state.content.fuga"
          @change="e => changeFuga({ value: e.target.value })"
          type="text"
        />
      </div>
      <div>
        <label>rootOptionMemo</label>
        <input
          :value="$store.state.content.option.memo"
          @change="e => changeOptionMemo({ value: e.target.value })"
          type="text"
        />
      </div>
      <button @click="save">保存</button>
    </div>
  </section>
</template>
<script>
import { mapActions, mapMutations } from 'vuex';

export default {
  methods: {
    ...mapActions(['save']),
    ...mapMutations([
      'changeHoge',
      'changeFuga',
      'changeOptionMemo',
    ]),
  },
};
</script>
```

```javascript:store/index.js
export const state = () => ({
  content: {
    hoge: '',
    fuga: 0,
    option: {
      memo: '',
    },
  },
  isInitialized: false
});

export const mutations = {
  changeHoge(state, payload) {
    state.content.hoge = payload.value;
  },
  changeFuga(state, payload) {
    state.content.fuga = payload.value;
  },
  changeOptionMemo(state, payload) {
    state.content.option.memo = payload.value;
  },
  changeIsInitialized(state, payload) {
    state.isInitialized = payload.value;
  },
};

export const actions = {
  init({ commit }) {
    // モック（本来はデータをサーバー側から取得してcommitする）
    commit('changeHoge', {
      value: 'test',
    });
    commit('changeFuga', {
      value: 10,
    });
    commit('changeOptionMemo', {
      value: 'Hello World!',
    });
    commit('changeIsInitialized', {
      value: true,
    });
  },
  save({ commit, state }) {
    // モック（本来はーバー側にリクエストしてそのレスポンスをcommitする）
    commit('changeHoge', {
      value: state.content.hoge,
    });
    commit('changeFuga', {
      value: state.content.fuga,
    });
    commit('changeOptionMemo', {
      value: state.content.option.memo,
    });
  },
};
```

# 【仕様追加】input要素を編集したらページ遷移の際にブラウザの標準ポップアップを出したい
さて、上記のようなフローで構成されたアプリケーションですがここでコンテンツを編集したらページ遷移の際にブラウザの標準ポップアップを出したい、保存したらそれを解除したいという仕様になりました。まず最初に考えつくのは該当する各Mutationsがcommitされたときとsave Actionに処理を追加するという実装方法です。

```javascript:store/index.js

// 中略

export const mutations = {
  changeHoge(state, payload) {
    state.content.hoge = payload.value;
    window.onbeforeunload = () => true; // 追加
  },
  changeFuga(state, payload) {
    state.content.fuga = payload.value;
    window.onbeforeunload = () => true; // 追加
  },
  changeOptionMemo(state, payload) {
    state.content.option.memo = payload.value;
    window.onbeforeunload = () => true; // 追加
  },
  changeIsInitialized(state, payload) {
    state.isInitialized = payload.value;
  },
};

export const actions = {
  init({ commit, state }) {
  // 中略
  window.onbeforeunload = () => undefined; // 追加
  },
  save({ commit, state }) {
  // 中略
  window.onbeforeunload = () => undefined; // 追加
  },
};

```

データフローは以下のようになります
![qiita tmp.001.jpeg](https://qiita-image-store.s3.amazonaws.com/0/308214/558b28e2-5864-ac06-2ec6-72e37ad63ff9.jpeg)


この実装でも実現可能ですが、アプリケーションの拡張を考えたときに以下の懸念点があります
- Mutationsが増える度に```window.onbeforeunload = () => true;```を追加する必要があり、冗長かつ漏れが発生する可能性がある
- そもそもStateを書き換えるのみというコンセプトのMutationsにこの処理が入っているのが微妙
- Mutationsで発火しているので、init処理の後にも```window.onbeforeunload = () => undefined;```を書く必要がある
- Actionsも同様で、例えば削除するActionができてその後にもアラートを無効にしたいなどというときにも１つ１つに書いていかないといけない


このように特に拡張性の部分についての問題のある設計となっています。またページ遷移の際にブラウザの標準ポップアップを出すようにする処理に関しては本来stateが変更したら変えたいという話なのにそれをMutationsでやってしまっているので、懸念点に加えて頑張って制御している感が出ていてあまりスマートではない書き方というのもありそうです。

# watchを使ってstateの変更を監視する
このような処理はVuexのwatchを使うとスマートに書くことができます。watchは指定した関数の戻り値が変化したときに実行される関数を登録できるもので、stateの変更起因での関数実行を実現できます。
watchを使うとコードは以下のようになります。


```javascript:store/index.js
const activateAlertStateKeys = ['content'];

// 中略

export const actions = {
  init({ commit }) {
    // 中略
    this.watch(
      state => activateAlertStateKeys.map(key => state[key]),
      () => window.onbeforeunload = () => true,
      {
        deep: true,
      }
    );

    commit('changeIsInitialized', {
      value: true,
    });
  },
  save({ commit, state }) {
    // 中略
    window.onbeforeunload = () => undefined;
  },
};


```

init処理の中でwatchを行うようにしました。
watchは第一引数に関数を渡し、この関数の戻り値が変わったときに第二引数の関数が発火します。第三引数の{ deep: true }を与えることで、対象のstateのネスト構造の変化も検知するようになります。
つまりactivateAlertStateKeysの要素とプロパティ名が同じstateに変更があったときに```window.onbeforeunload = () => true;```が走って、以後```window.onbeforeunload = () => undefined;```を行うsaveが走るまではページ遷移時にブラウザの標準ポップアップが表示されます。init Action内でcontentの変更を行っているchangeHogeなどによりwatcherが発火しないようにするため、それらがcommitされた後にwatchで登録を行っています。
activateAlertStateKeysを配列で管理することで、別のstateを監視したくなったときもこの配列にそのプロパティ名を追加するのみで管理できますし、またコードを読むときもactivateAlertStateKeysを見るだけで監視しているプロパティ名が分かるという利点もあります。

# subscribeActionを使ってActionのdispatchを監視する
stateの変更検知に関してはうまくいくようになりましたが、今後delete Actionが追加されそのActionのdispatch時もページ遷移時のブラウザの標準ポップアップ表示を無効化したい、というような時のために無効化する処理もwatchのようにactionのdispatch起因でできると良いです。そういった処理を実現するためにVuexにはsubscribeActionが用意されています。subscribeActionを使用して実現する処理は以下のようになります

```javascript:store/index.js
const invalidateAlertActions = ['save'];

// 中略
export const actions = {
  init({ commit, getters }) {
    // 中略

    this.subscribeAction(action => {
      if (invalidateAlertActions.includes(action.type)) {
        window.onbeforeunload = () => undefined;
      }
    });
    commit('changeIsInitialized', {
      value: true,
    });
  },
  // 中略
};

```
subscribeActionはactionがdispatchされたときに第一引数に渡された関数を実行するものです。関数はactionの情報を引数として持っており、action.typeでdispatchされたaction名、action.payloadでactionに渡されたpayloadを取得できます。

これを利用して、action.typeがsaveだったときにページ遷移時のブラウザの標準ポップアップ表示を無効化しています。こちらも配列で管理することでsave以外にも特定のActionのdispatch起因で無効化したい場合は配列の要素としてAction名を追加するのみで実現できます。

# まとめ
最終的なデータフローは以下のようになりました。
![qiita tmp.001.jpeg](https://qiita-image-store.s3.amazonaws.com/0/308214/7b232309-05df-e9a1-95f9-a0854fdd1892.jpeg)

このように単方向データフローを維持したまま拡張をすることができました。stateとactionを囲っている点線は簡単に拡張できる設計にもなっており、拡張性のある単方向データフロー構成となっています。

日々状態管理をシンプルにする、特に単方向データフローを崩さないことは意識していますがVuexにはwatch、subscribeActionなどそれを可能にする機能が用意されているので、駆使することである程度複雑な処理でも綺麗に実装することは可能だと思いました。またそういったところにVuex開発の楽しさがあるのかなと感じました。

# このプロジェクトのリポジトリ
https://github.com/yuki8888nm/watch_and_subscribeAction