🤗

Nuxtで子コンポーネントから親コンポーネントにglobalなステートを反映するのは注意 🥲 🤗

2024/10/02に公開

NuxtでuseStateを用いてグローバルなステートを管理した時に大きな落とし穴にハマりました!
あまりこの動作について言及されている記事が無かったので忘備録として残します!

SSR + useStateでグローバルなステートを管理する時には要注意!

NuxtでuseStateもしくはpiniaなどを用いてステートをグローバルに管理したい時などよく発生するかと思います。
この時、ステートに注入するタイミングやコンポーネントの親子関係、レンダリング方法にはかなり注意が必要です!

達成したいこと

今回はuseStateの初期条件に🥲を宣言して、これを🤗にするということを達成させたいと思います。
泣いている人がいたら笑顔にさせたいですよね🤗

composables/useEmoji.ts
export const useEmoji = () => {
  const emojiState = useState('emojiState', () => '🥲') // これを🤗にしたい

  return { emojiState }
}

🙅‍♂️ NGパターン:SSR + 子コンポーネントでステートを更新する

正解パターンからですと、UIの反映が実質見えず分かりづらいので先にNGパターンを紹介します。

まずはParentコンポーネントChildコンポーネントを用意します。

components/Parent.vue
<template>
  <div>
    Parent: {{ emojiState }}
    <Child :state-change-timing="props.stateChangeTiming" />
  </div>
</template>

<script lang="ts" setup>
import type { PropType } from 'vue';
import Child from '~/components/Child.vue'
import { useEmoji  } from '~/composables/useEmoji';

const props = defineProps({
  stateChangeTiming: {
    type: String as PropType<'parent' | 'child'>,
    required: true
  }
})

const { emojiState } = useEmoji();

if (props.stateChangeTiming === 'parent') {
  emojiState.value = '🤗'
}
</script>
components/Child.vue
<template>
  <div>
    Child: {{ emojiState }}
  </div>
</template>

<script setup lang="ts">
import { useEmoji  } from '~/composables/useEmoji';

const props = defineProps({
  stateChangeTiming: {
    type: String as PropType<'parent' | 'child'>,
    required: true
  }
})

const { emojiState } = useEmoji();

if (props.stateChangeTiming === 'child') {
  emojiState.value = '🤗'
}
</script>

ページではParentコンポーネントを読みこんで、ステートをチェンジするコンポーネントをpropsで指定するだけです。

pages/ssr-child-to-parent
<template>
  <Parent :state-change-timing="'child'" />
</template>

<script setup lang="ts">
import Parent from '~/components/Parent.vue';
</script>

state-change-timingが'parent'の時はParentコンポーネントでstateを更新し、'child'の時はChildコンポーネントでstateを更新します。

リロードをすると、一瞬🥲がちらついて🤗に変わりましたね!泣いている顔なんて見たくない、、、(一瞬画像が灰色になるのはご了承ください、gifにしているため、、、)
まだUIのちらつきだけで済めば良いですが、ステートを参照して何か動くようなロジックがある場合にはバグが発生してしまう可能性が非常に高まります。

useStateはグローバルなステート管理なため、どこでステートを変更してもいいように思えてしまいますが、どうしてこんなことが起きるのか?
これは、レンダリングされる順番にあります!

VueまたはReactといったフロントエンドのライブラリは一般的に、親コンポーネントから子コンポーネントに向かってレンダリングされていきます。

今回のケースですと、以下のような動きでレンダリング、ステートの参照が行われます。

SSR時(サーバーサイドでの処理)
1. Parentコンポーネントがレンダリングされる。この時のemojiStateは初期状態で変更していないため🥲がレンダリングされる。
2. Childコンポーネントがレンダリングされる。この時、emojiStateが変更され🤗がレンダリングされる。

↓↓↓↓↓↓

Hydration時(ブラウザ側での処理)
1. サーバーから返却されるHTMLはSSRされたものなので、上述した通りParentコンポーネントでは🥲が、Childコンポーネントでは🤗が表示される。
2. ここでハイドレーションが発生。javascriptの処理で、emojiStateが🤗に更新されるため、ParentコンポーネントとChildコンポーネント共に🤗に変化する。

試しにcurl等で問い合わせると、🥲と🤗どちらも表示されていることが分かりますね。

ブラウザのコンソール画面にもHydration時に不整合が出てますよというwarningが出ています。

子コンポーネントでglobalなステート(useStateやpinia)を更新してしまうと、このような不具合が発生してしまいました。

🙆‍♂️ OKパターン:SSR + 親コンポーネントでステートを更新する

続いてはOKパターン。子コンポーネントではなく親コンポーネントでステートを更新するようにしてみます。

pages/ssr-parent-to-child
<template>
  <Parent :state-change-timing="'parent'" />
</template>

<script setup lang="ts">
import Parent from '~/components/Parent.vue';
</script>

そうすると、更新した時にちらつきが発生しなくなります!変化が無いので🥲を見なくて済みましたね!(今度は灰色のgifになってしまいました汗)

これは、ステートの更新タイミングが問題なく、期待通りに動いてくれたためです。
先ほどと同様にレンダリングとステートの管理タイミングについて考えてみましょう。

SSR時(サーバーサイドでの処理)
1. Parentコンポーネントがレンダリングされる。この時、emojiStateが変更され🤗がレンダリングされる。
2. Childコンポーネントがレンダリングされる。emojiStateはParentコンポーネントで変更済みなため🤗がレンダリングされる。

↓↓↓↓↓↓

Hydration時(ブラウザ側での処理)
1. サーバーから返却されるHTMLはSSRされたものなので、上述した通りParentコンポーネント・Childコンポーネントで🤗が表示される。
2. ハイドレーションが発生。クライアントサイドでもemojiStateが🤗に更新されるが、SSRで生成されたHTMLでも同様な状態になるので特に変化しているように見えない。

という流れになるため、無事に🥲が表示されませんでした。念の為こちらもcurlで確認しましょう。

SSRで返却されるHTMLも問題なく🤗で返されていることが分かりますね!Hydration時のエラーも表示されなくなり、無事に期待通りに動くことが確認できました!

🙆‍♂️ OKパターン:SPAのとき

実はSSRではなく、SPAの時はParent・Childコンポーネントどちらでステートを更新しても問題ありません!

pages/spa
<template>
  <div>
    <Parent :state-change-timing="'child'" />
  </div>
</template>

<script setup lang="ts">
import Parent from '~/components/Parent.vue';
</script>

動作はSSR + 親コンポーネントと同じなので、gif等は省略します。

これは、SPAの時はSSRと異なり何も入っていない空のHTMLが返却され、クライアントサイド(ブラウザ)でDOM構造を構築しステート管理を行うため、どのコンポーネントでステートを変更しても問題ないためです。ただし、色々なところでstateを更新してしまうと、どのステートが優先されるか分かりにくくなってしまうので、いい方法とは言いづらいですね。

結論!!

長々となってしまいましたが、結論は以下のようになります。

  1. Nuxt等フロントエンドのフレームワークでグローバルなステートを更新するときは、親から子に向かってレンダリングが行われるため、極力親コンポーネント側で行う。子コンポーネントでグローバルなステートを管理するとバグを引き起こす可能性が高まるため注意が必要。
  2. SPAの時はクライアントサイドでのみステートの管理が行われるため、Hydration時のエラーは発生しなくなる。しかし、予期せぬエラーは起きる可能性はあるため、ステートの更新は極力親コンポーネントで行うのがベター。

Nuxt3から用意されたuseStateというAPIがサーバーサイドでもクライアントサイドでも使えるというのが非常にシンプルな仕組みがゆえに、思わぬ落とし穴になる可能性もあります。

今回の例は絵文字を変えるだけのかなりシンプルなものでしたが、ここにAPIをfetchするような仕組みが入って来たりすると途端にハマりやすいバグだと思いますので、困った方の参考にれば嬉しいです!

今回作成したサンプル github
https://github.com/SonedaYusuke/nuxt-ssr-global-state-not-sync

参考

この記事はこちらの動画を参考にさせていただきました!非常に分かりやすい動画で感謝です🙏

https://www.youtube.com/watch?v=dZSNW07sO-A

Discussion