Vue 3 + Piniaでアプリ全体のUI状態を管理

に公開

はじめに

Vue 3 Composition APIでは、コンポーネントごとに状態を管理できます。しかし、アプリ全体で使う UI 要素の状態、たとえばモーダルやスナックバー、サイドパネルの開閉状態は、コンポーネントの階層に依存せずに操作できる必要があります。

従来の方法では、props や composable を使って状態を共有することもできます。しかし、アプリの構造が複雑になると、prop drilling(親から子へ順番に props を渡すこと)や、コンポーネント間で状態を同期させる処理が増えてしまい、保守が難しくなります。

このような場合に便利なのが Pinia です。Pinia を使うと、アプリ全体で共通の状態を簡単に管理できます。

Pinia でサイドパネルの状態管理

サイドパネルの状態をグローバルに管理する

たとえば、チャットアプリで左側にスライドするサイドパネルを考えてみます。パネルを開くボタンは、ページの内部だけでなく、共通レイアウトのヘッダーなど複数の場所に存在するかもしれません。

このような場合、各コンポーネントでローカルな ref や composable を使うと、状態の同期が複雑になります。Pinia でグローバルストアを作ると、アプリ全体で 1つの状態 を共有できます。

import { defineStore } from 'pinia';
import { ref, shallowRef, type Component } from 'vue';

export const useUIPanelStore = defineStore('uiPanel', () => {
  const isOpen = ref<boolean>(false);
  const content = shallowRef<Component | null>(null);

  const open = (component: Component) => {
    content.value = component;
    isOpen.value = true;
  }

  const close = () => {
    isOpen.value = false;
    content.value = null;
  }

  return { isOpen, content, open, close };
});

shallowRef を使うことで、コンポーネントインスタンス全体をリアクティブに監視するオーバーヘッドを避けられます。

レイアウト側での表示

レイアウトに一度だけサイドパネルコンポーネントを配置し、Pinia ストアの状態に応じて表示します。こうすることで、ルーター内外のどこからでも開閉可能になり、DOM の重複や prop drilling を避けられます。

<script setup lang="ts">
import { useUIPanelStore } from '@/stores/uiPanel';
const panel = useUIPanelStore();
</script>

<template>
  <teleport to="body">
    <div v-if="panel.isOpen" class="sidepanel">
      <component :is="panel.content" @close="panel.close" />
    </div>
  </teleport>
</template>

<style>
.sidepanel {
  position: fixed;
  left: 0;
  top: 0;
  width: 300px;
  height: 100%;
  background-color: #fff;
  box-shadow: 2px 0 8px rgba(0,0,0,0.2);
}
</style>

teleport を使うと、サイドパネルの要素をコンポーネントの DOM ツリー外(ここでは <body> の直下)にレンダリングできます。これにはいくつかのメリットがあります。

たとえば、通常のコンポーネント内でパネルを表示すると、親コンポーネントの CSS に影響を受けてしまうことがあります。
特に次のようなケースです:

  • 親要素に overflow: hidden が指定されていて、パネルが見切れてしまう
  • z-index の競合が起きて、他の要素の下に隠れてしまう
  • 特定のレイアウトコンテナ(Flex や Grid)の影響で意図しない位置に描画される

teleport で <body> 直下に移すことで、これらの制約を避けられ、常に最前面に安定して表示できます。

モーダル、スナックバー、ドロップダウンメニューなど、「どのページ階層からでも安全に表示したい UI」では特に有効です。

使用例

ボタンからサイドパネルを開く場合、どこからでも Pinia ストアを呼び出すだけです。

<script setup lang="ts">
import { useUIPanelStore } from '@/stores/uiPanel';
import UserInfoPanel from '@/components/UserInfoPanel.vue';

const panel = useUIPanelStore();

const openUserInfo = () => {
  panel.open(UserInfoPanel);
}
</script>

<template>
  <button @click="openUserInfo">ユーザー情報を表示</button>
</template>

この方法なら、ボタンの位置がどこにあっても、パネルの DOM は一つで済みます。モーダルやスナックバーなど他のグローバルUIも同じ考え方で管理できます。

スナックバーの例

グローバルスナックバーをPiniaで管理

サイドパネルと同じ考え方で、スナックバーの表示も Pinia ストアで管理できます。こうすることで、アプリのどこからでもメッセージを表示でき、prop drilling を避けられます。

import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useSnackbarStore = defineStore('snackbar', () => {
  const message = ref<string>('');
  const isVisible = ref<boolean>(false);

  const show = (msg: string, duration = 3000) => {
    message.value = msg;
    isVisible.value = true;

    setTimeout(() => {
      isVisible.value = false;
      message.value = '';
    }, duration);
  }

  return { message, isVisible, show };
});

レイアウト側での表示


<script setup lang="ts">
import { useSnackbarStore } from '@/stores/snackbar';
const snackbar = useSnackbarStore();
</script>

<template>
  <teleport to="body">
    <div v-if="snackbar.isVisible" class="snackbar">
      {{ snackbar.message }}
    </div>
  </teleport>
</template>

<style>
.snackbar {
  position: fixed;
  bottom: 16px;
  left: 50%;
  transform: translateX(-50%);
  background-color: #323232;
  color: #fff;
  padding: 12px 24px;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
</style>

使用例

どこからでも簡単にスナックバーを表示できます。

<script setup lang="ts">
import { useSnackbarStore } from '@/stores/snackbar';
const snackbar = useSnackbarStore();

const showMessage = () => {
  snackbar.show('データが保存されました!');
}
</script>

<template>
  <button @click="showMessage">保存</button>
</template>

Pinia を使うことで、アプリ全体で一つのスナックバーを共有でき、DOM の重複や props のやり取りを気にせずに済みます。

Pinia を使う利点

  • コンポーネント間で状態を共有できる
  • prop drilling を避けられる
  • DOM の重複を防げる
  • Teleport と組み合わせて柔軟に表示可能
  • 簡単に開閉やメッセージ更新が可能

最後に

Pinia は、ビジネスロジックだけでなく、グローバル UI の状態管理にも非常に便利です。単純なトグルのように見えるサイドパネルやスナックバーでも、アプリ全体で中央管理することで、コードの可読性と保守性が向上します。

コンポーネント間の状態共有で悩んでいる場合は、まず Pinia を使ったグローバルストアを試してみることをおすすめします。

株式会社アクトビ

Discussion