💻

Nuxt 3 + TypeScript で状態管理(useState編)

2021/11/23に公開

Nuxt 3 の useState を使った State 管理(状態管理)の記事です。

Nuxt 3 では Vuex がサポートされていません。
Vue 3 に対応した Vuex 5.x がリリースされるのを待たずとも Nuxt 3 にもいち早く対応した Pinia を使って State 管理をすることは可能です。

しかし、それほど大規模なアプリケーションでなければ useState と Composables (Composition Function) を組み合わせることで非常に簡単に State 管理をすることが可能になります。

Pinia は Vue Devtools で State の変化を確認できるなど、いくつものメリットがあります。

もっとも端的な useState() の使い方

もっとも端的に使う場合は次のように利用可能です。

app.vue
<script setup lang="ts">
const counter = useState('counter', () => 0)
</script>

<template>
  <div>
    Counter: {{ counter }}
    <button @click="counter++"> + </button>
    <button @click="counter--"> - </button>
  </div>
</template>

また Composable Function にすることも可能です。

composables/states.ts
export const useBasicCounter = () => useState('counter', () => 0)

ただ、このような使い方は、本格的にアプリ内で共有する値を設定するには注意が必要です。
グローバルなステートをどこからでも自由に変更ができてしまいます。
(どこからでも counter.value = 5 のように mutate できてしまう)

そのため、次のような対応を検討したいところです。

  • コンポーネントでは Vuex の getter のように readonly なステートを使用したい
  • 値の操作は事前に用意した関数を通じて行いたい

また、第1引数の key を typo してしまうと、意図せず別のステートが使用されます。
これらを解決するために Composable Function を使用し Vuex や Pinia ライクな状態管理ができるようにしたいと思います。

Composable Function でリアクティブな値を利用する

まずは Composable Function の使い方の基本を説明します。

Composition API の登場により、アプリ内で利用するリアクティブな値を(Component と切り離して)準備することはとてもシンプルになりました。

composables/counter.ts
import type { Ref } from 'vue'
export const inc = (counter: Ref<number>) => () => counter.value++
export const dec = (counter: Ref<number>) => () => counter.value--

export const useNotSharedCounter = () => {
  const counter = ref(0)
  return {
    counter: readonly(counter),
    inc: inc(counter),
    dec: dec(counter),
  }
}

このような Composable Function を Component 内で呼び出します。

components/NotSharedCounter.vue
<script setup lang="ts">
const { counter, inc, dec } = useNotSharedCounter()
</script>

<template>
  <div>
    カウンター: {{ counter }}
    <button @click="inc"> + </button>
    <button @click="dec"> - </button>
  </div>
</template>

複数の Component から簡単に利用することが可能です。
しかし、たとえばページ遷移をするなどして Component が Unmount されると、再度 Component を呼び出した際にふたたび useNotSharedCounter() が呼び出されるため、カウンターは初期値に戻ってしまいます。

アプリ内の状態管理をしたい場合にはこれでは用をなしません。

では、ということで useNotSharedCounter() の外側で初期化しようとすると、サーバーサイドで値が共有されてしまうため Nuxt 3 のような ISG(SSR) が基本となるアプリケーションだと思わぬ脆弱性を生みます。

ISG で脆弱性となりうる例

次のような記述はこれまで以上に気をつける必要があります。

composables/counter.ts
const counter = ref(0)  // このように useCounter() の外側で初期化すると他のユーザーと共有されてしまうので脆弱性となりえます。

export const useDangerCounter = () => {
  return {
    counter: readonly(counter),
    inc: () => counter.value++,
    dec: () => counter.value--,
  }
}

useState() を使って簡単に状態管理を行う

Nuxt 3 で用意されている useState() を Composable Function のなかで利用することで、とても簡単で、かつ安全に State をアプリ全体で扱うことが可能です。

composables/useSharedCounter.ts
import type { Ref } from 'vue'
export const inc = (counter: Ref<number>) => () => counter.value++
export const dec = (counter: Ref<number>) => () => counter.value--

export const useSharedCounter = () => {
  const counter = useState('counter', () => (0))
  return {
    counter: readonly(counter),
    dec: dec(counter),
    inc: inc(counter),
  }
}

これだけで、どの Component から呼び出しても、同じ counter の値を利用できます。

counter はリアクティブな値となるので setup() 内では counter.value のように利用します。

components/SharedCounter.vue
<script setup lang="ts">
const { counter, inc, dec } = useSharedCounter()
onMounted(() => { console.log(counter.value) })
</script>

<template>
  <div>
    カウンター: {{ counter }}
    <button @click="inc"> + </button>
    <button @click="dec"> - </button>
  </div>
</template>

useState() の基本

useState() の型定義はこのようになっています。

useState<T>(init?: () => T | Ref<T>): Ref<T>
useState<T>(key: string, init?: () => T): Ref<T>

第2引数に初期化をする関数を指定しますが、この戻り値の型が推論可能な場合は、いっさい型を意識することなく型がついた状態で利用できます。
たとえば次のような場合は string になります。

composables/useArticleTitle.ts
export const useArticleTitle = () => {
  const title = useState('title', () => 'No Title')
  return { title }
}

任意に指定する場合は次のようになるでしょう。

composables/useArticleTitle.ts
export const useArticleTitle = () => {
  const title = useState<string | string[]>('title', () => 'initial string')
  return { title }
}

title の型は Ref<string | string[]> となります。

初期化関数を指定しない場合や null にする場合は、型を指定しないと unknownany となってしまいます。

Component ではたとえば次のようになります。

components/ArticleTitle.vue
<script setup lang="ts">
const { title } = useArticleTitle()
// 配列の場合は , で結合
const joined = computed(() => Array.isArray(title.value) ? title.value.join() : title.value)
onMounted(() => title.value = ['Co-Edo', 'ktanaka'])
</script>

<template>
  <div>
    {{ joined }}
  </div>
</template>

まとめ

Nuxt 3 の利点のひとつに TypeScript フレンドリーな開発体験があります。
より簡単に扱えて、より安全に利用できる useState() をぜひ活用していきたいですね。

Discussion