44
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vue #3Advent Calendar 2019

Day 22

Vue Composition APIでストアパターンをスマートに使って状態管理をする

Last updated at Posted at 2019-12-23

TL;DR

  • [PR] Reactの状態管理ライブラリ「unstated-next」をVue Composition APIベースに移植したよ
  • 状態をComposition APIで共有し、Read/Writeできるので便利だよ
  • 特定のコンポーネントツリーでしか利用しないようにスコーピングができるよ
  • 型もばっちり効くよ

Vue Composition APIのRFCもマージされ、Vue3のalphaもひっそりとリリースされていて、もういくつ寝るとVue3!という雰囲気になってきました。多くのVue.jsユーザがComposition APIの実戦投入について検討をしたり、それに向けた素振りをしているのではないかと思います。今年のアドベントカレンダーでも多くのアドベントカレンダーがComposition APIに触れていたり、いくつかの記事がストアの設計を絡めていて、自分も記事を横にサンプルコードを書いてみたりしました。

ところで、hooksで先行しているReactにはunstated-nextという必要最低限の実装(なんとTSで38行!)でとてもシンプルなライブラリがあります(使い方や仕組みは後述します)。過日業務で使ってみたのですが、導入したエンジニアのアツい推薦も納得するほどの使い勝手の良さでした。

Vueスタックでも同様の状態管理を行えたらなとぼんやりと思っていたのですが、特に同様のライブラリが存在しないようだったのと、Composition APIのドキュメントを読んでいる際に移植が可能だとわかったので作ってみました

[Github] : https://github.com/resessh/vue-unstated
[npm] : https://www.npmjs.com/package/vue-unstated

以下ライブラリの宣伝をしながら解説をする記事になります :pray:

なぜComposition APIだけではだめなのか

※ Composition APIを理解している方は読み飛ばしてください。

Composition APIはそもそもロジックの再利用性を高めることを目的とした仕様です。例えば以下のようなComposition Functionがあるとします。

use/counter.js
export const useCounter = () => {
  // 状態
  const state = reactive({ count: 0 })

  // 状態を変更する関数
  const increment = () => {
    state.count++
  }

  return {
    state,
    increment,
  }
}

上記のComposition Functionをコンポーネントで使うには以下のように呼び出します。

App.vue
<template>
  <div>
    <!-- クリックしたらカウントを増やす -->
    <button @click="increment">+</button>
    <!-- カウントを表示する -->
    <p>{{ count }}</p>
  </div>
</template>

<script>
import { useCounter } from 'use/counter'

export default {
  setup() {
    // Composition Functionを実行し、初期化された状態とメソッドを取り出す
    const { state, increment } = useCounter()

    // templateに状態・メソッドを渡す
    return {
      count: state.count,
      increment,
    }
  }
}
</script>

使う際に useCounter() でComposition Functionを実行していることから察せられるように、各コンポーネントで useCounter を利用しても状態は共有されず、それぞれのコンポーネント内で別々のインスタンスを初期化しているような動作になります。Composition APIは上記コードのとおり、状態とロジックを再利用可能な形で切り出すことがとてもきれいにできる反面、切り出した状態を共有する仕組みは提供されていません。
つまり、このカウンターの状態を共有したい場合は、初期化されたComposition Functionの中身を1何かしらの方法でコンポーネントをまたがって共有しなければなりません

Vue Composition API を使ったストアパターンと TypeScript の組み合わせはどのくらいスケールするか? の記事ではこれを実現するために、 Vueの provide/inject というAPIを使っています。このAPIは**「コンポーネントツリーのルートの方でprovideしたものは、同一コンポーネントツリー内であればどんなにルートから遠いコンポーネントでも inject だけで呼び出すことができる」**という機能を利用しています。

今回作ったvue-unstatedもこの provide/inject を利用していて、基本的に同じ方法・方針で状態管理をしようとしています

デモ

実際にコードを見たり触ったりできるデモはこちらになります。
Edit [vue-unstated DEMO] Todo

vue-unstatedの使い方

ここからは上記デモのコードを使ってvue-unstatedの使い方について簡単に触れたいと思います。

:one: Composition Functionをつくる

まずはVue Composition APIだけでComposition Functionを作ります。
今回はアイテムを登録することができるだけのTodoListを作ります。

use/todos.js
import { reactive } from "@vue/composition-api"

const useTodos = () => {
  const state = reactive({
    items: [],   // Todoのアイテムの配列
    latestId: 0, // 最新のアイテムのid
  })

  const addItem = item => {
    state.latestId++
    // Todoは { id: number, title: string } の構造
    state.items.push({
      id: state.latestId,
      title: item.title
    })
  }

  return {
    items: state.items, // 共有したい状態はitemsだけなので、これだけexportする
    addItem,
  }
}

:two: unstatedコンテナを作る

次に、 vue-unstatedcreateContainer でunstatedコンテナにしてexportします。

use/todos.js
+import { createContainer } from 'vue-unstated'
import { reactive } from "@vue/composition-api"

const useTodos = () => {
  const state = reactive({
    items: [],   // Todoのアイテムの配列
    latestId: 0, // 最新のアイテムのid
  })

  const addItem = item => {
    state.latestId++
    // Todoは { id: number, title: string } の構造
    state.items.push({
      id: state.latestId,
      title: item.title
    })
  }

  return {
    items: state.items, // 共有したい状態はitemsだけなので、これだけexportする
    addItem,
  }
}

+export default createContainer(useTodos)

:three: 使いたいコンポーネントの親でprovideする

続けて、使いたいコンポーネントツリーのルートに近いコンポーネント(親側のコンポーネント)でコンテナをprovideします。

App.vue
 <template>
   <div id="app">
     <todo-register/>
     <todo-list/>
   </div>
 </template>
 
 <script>
 import TodoRegister from "./components/TodoRegister.vue"
 import TodoList from "./components/TodoList.vue"
+import TodoContainer from "./use/todos"
 
 export default {
   name: "App",
   components: {
     TodoRegister,
     TodoList,
   },
   setup() {
     // const { items, addItem } = TodoContainer.provide() で即座に使うこともできます
+    TodoContainer.provide()
   }
 }
 </script>

:four: 使いたいコンポーネントでuseContainerする

最後に使いたい子コンポーネントでコンテナを useContainer します。

Todoアイテムのリスト

components/TodoList.vue
 <template>
   <ul class="list">
     <li v-for="item in items" :key="item.id">
       <todo-item :item="item"/>
     </li>
   </ul>
 </template>
 
 <script>
 import TodoItem from "./TodoItem.vue"
+import TodoContainer from "../use/todos"
 
 export default {
   name: "TodoList",
   components: {
     TodoItem
   },
   setup() {
+    const { items } = TodoContainer.useContainer()
+    
+    return { items }
   }
 };
 </script> 

Todoアイテムを登録するフォーム

components/TodoRegister.vue
 <template>
   <form @submit.prevent="onSubmit">
     <input type="text" v-model="state.title">
     <button type="submit">add</button>
   </form>
 </template>
 
 <script>
 import { reactive } from "@vue/composition-api"
+import TodoContainer from "../use/todos"
 
 export default {
   name: "TodoRegister",
   setup() {
+    const { addItem } = TodoContainer.useContainer()
     const state = reactive({
       title: ""
     })
     const onSubmit = () => {
+      addItem({ title: state.title }) // Todoアイテムの登録
       state.title = ""
     }
 
     return {
       state,
       onSubmit
     }
   }
 }
 </script>

これだけでTodoアイテムを登録・閲覧できるようになります。
Vue Composition APIを使ってロジックを切り出しただけの状態と比べてもほぼ差がないのがわかるでしょうか?

動作の仕組み

本家unstated-nextも38行と大変短いコードですが、ほぼ同様の実装である vue-unstated も34行とわずかなコードで動いています。
基本的な方針は上述の Vue Composition API を使ったストアパターンと TypeScript の組み合わせはどのくらいスケールするか? の 「兄弟コンポーネント間でストアを共有」の項で示されている provide/inject でのストアオブジェクトの共有とほぼ同一です。
強いて挙げるとすれば、unstatedではComposition Functionのインスタンスをまるごと共有しようという発想である点でしょうか?はじめてunstated-nextのコードを読んだ時は、腰が抜けてイスから転げ落ちました。

興味がある方はぜひソースを読んでみてください。
https://github.com/resessh/vue-unstated/blob/master/src/index.ts

また、フィーチャーリクエストやコントリビューションもwelcomeですが、どうしてもプロジェクト内の固有の理由で変更を加えたい場合は、プロジェクトのリポジトリにコピペして編集してしまったほうが早いかもしれません2。34行しかないし、アクティブに変更も無いのではないかと思っています。

vue-unstatedを使うことのメリット

vue-unstated を使うことで得られるメリットがいくつかあるので触れておきます。

1. 自分でアノテーションしなくてもバッチリ型がつく

provide/inject を自分で叩いてストアを共有する場合、 inject<HogeStore>(key) のようにアノテーションでお型付けをする必要があります
しかし、unstatedコンテナでラップすると、 Container.useContainer() だけでバッチリ型がついたStoreが返ってきます

2. 簡単にstoreの依存関係を表現できる

下記コード例のように、ストア同士の依存関係をシンプルに記述することができます3

// 検索結果をfetchするだけのComposition
const useSearchResult = () => {
  // ...
  return { result, search }
}
export const SearchResultContainer = createContainer(useSearchResult)

// 検索結果をフィルタするだけのComposition
const useFilteredSearchResult = () => {
  // 検索結果をfetchするコンテナを利用する
  const { result } = SearchResultContainer.useContainer();
  // フィルタする処理をかける(実際はフィルタのパラメータの状態を持ったりしてもっと複雑になる)
  const filteredSearchResult = SomeFilterFunction(result);

  return { result: filteredSearchResult }
}
export const FilteredSearchResultContainer = createContainer(useFilteredSearchResult)

3. 利用しているコンポーネントツリーのインスタンスがGCされた場合、ストアもGCされる。

大規模なアプリケーションを運用していると、主にパフォーマンスの関係で、各ページをDynamic importしたり、いらない状態を消したかったりするのではないでしょうか。vue-unstatedのメリットというより、provide/injectのメリットですが、provideしたコンポーネントが消えれば参照カウントでunstatedインスタンスもGCされるため、今表示されているコンポーネントツリーにだけ必要な状態を持つことが可能です。

まとめ

Vue Composition APIはまだまだこれからといったフェーズなので、nuxtのサーバサイドプロセスからのhydrationなど、Vuexから離れられなかったり、その他の状態管理方法にそれぞれの必要性や良さがあるのでないかと思っています。
ですが、今回ご紹介した provide/inject 方式は、いまの所使い勝手の良さからメインストリームになってもおかしくないと思っています。
これから事例を積み上げていってpros/consを精緻にしたり、さらなるベストプラクティスやバッドプラクティスを見つけていけたらなと思っています。
もしライブラリを使ってもらえた場合、気軽にフィードバックいただけるとありがたいです :pray:


参考記事

  1. この場合 useCounter() で得られた { state, increment } を指す。

  2. unstated-next を利用していたときは、react-routerとの兼ね合いで、コードを変更せざるを得なかったことがあった気がします。

  3. まだ実際に動かして試してないので、動かなかったらメンゴです。動くように直します。

44
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?