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
以下ライブラリの宣伝をしながら解説をする記事になります
なぜComposition APIだけではだめなのか
※ Composition APIを理解している方は読み飛ばしてください。
Composition APIはそもそもロジックの再利用性を高めることを目的とした仕様です。例えば以下のようなComposition Functionがあるとします。
export const useCounter = () => {
// 状態
const state = reactive({ count: 0 })
// 状態を変更する関数
const increment = () => {
state.count++
}
return {
state,
increment,
}
}
上記のComposition Functionをコンポーネントで使うには以下のように呼び出します。
<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
を利用していて、基本的に同じ方法・方針で状態管理をしようとしています。
デモ
vue-unstatedの使い方
ここからは上記デモのコードを使ってvue-unstatedの使い方について簡単に触れたいと思います。
Composition Functionをつくる
まずはVue Composition APIだけでComposition Functionを作ります。
今回はアイテムを登録することができるだけのTodoListを作ります。
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,
}
}
unstatedコンテナを作る
次に、 vue-unstated
の createContainer
でunstatedコンテナにしてexportします。
+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)
使いたいコンポーネントの親でprovideする
続けて、使いたいコンポーネントツリーのルートに近いコンポーネント(親側のコンポーネント)でコンテナをprovide
します。
<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>
使いたいコンポーネントでuseContainerする
最後に使いたい子コンポーネントでコンテナを useContainer
します。
Todoアイテムのリスト
<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アイテムを登録するフォーム
<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を精緻にしたり、さらなるベストプラクティスやバッドプラクティスを見つけていけたらなと思っています。
もしライブラリを使ってもらえた場合、気軽にフィードバックいただけるとありがたいです
参考記事
- Vue Composition API を使ったストアパターンと TypeScript の組み合わせはどのくらいスケールするか?
- 先取りVue 3.x !! Composition API を試してみる