💻

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

4 min read

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

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

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

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

Composition 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 useCounter = () => {
  const counter = ref(0)
  return {
    counter: readonly(counter),
    inc: inc(counter),
    dec: dec(counter),
  }
}

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

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

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

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

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

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

ISG で脆弱性となりうる例

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

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

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

『Component の setup() か、もしくは onMounted() のような Life Cycle Hook の中 かどうか』を意識し、その外側にあるものはユーザー固有の値であってはいけません。

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

Nuxt 3 で用意されている useState() を Composition 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>(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', () => null)
  return { title }
}

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

null については記述する必要がないようです。
また、初期化関数を指定しない場合や 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

ログインするとコメントできます