T-CREATOR

Svelteのストア機能でグローバル状態管理を実現

Svelteのストア機能でグローバル状態管理を実現

Web開発において、アプリケーションが複雑になるにつれて避けて通れないのが状態管理という課題です。特にSPA(Single Page Application)では、複数のコンポーネント間でデータを効率的に共有する仕組みが不可欠になります。

「またpropsを何層にも渡すの?」「このデータ、どこで管理すればいいの?」そんな悩みを抱えたことはありませんか。Reactの開発経験がある方なら、Context APIやprops drillingの煩雑さに頭を悩ませたこともあるでしょう。

そんな状態管理の複雑さを、Svelteは驚くほどシンプルに解決してくれます。Svelteのストア機能を使えば、わずか数行のコードでグローバル状態管理が実現できるのです。この記事では、初心者の方でも安心してSvelteストアを活用できるよう、基本的な使い方から実践的な例まで丁寧に解説していきます。

背景

SPAにおける状態管理の重要性

現代のWebアプリケーションは、ユーザーとのインタラクションが豊富で、リアルタイムに状態が変化します。ユーザーがログインしているかどうか、ショッピングカートに何が入っているか、現在どのページを表示しているかなど、これらの情報を適切に管理することがアプリケーションの品質を左右するのです。

SPAでは、ページの再読み込みが発生しないため、これらの状態をJavaScript内で保持し続ける必要があります。しかし、状態が散在してしまうと、データの整合性を保つのが困難になってしまいますね。

Reactのコンテキストやコンポーネントpropsによる状態管理の複雑さ

Reactでの状態管理を経験されたことがある方なら、以下のような課題に直面したことがあるでしょう。

props drilling問題では、親コンポーネントから子、孫コンポーネントへとpropsを延々と渡し続ける必要があります。中間のコンポーネントは実際にはそのデータを使わないのに、ただ下の階層に渡すためだけにpropsを受け取らなければならないのです。

javascript// props drillingの例(Reactの場合)
function App() {
  const [user, setUser] = useState(null);
  return <Header user={user} setUser={setUser} />;
}

function Header({ user, setUser }) {
  return <Navigation user={user} setUser={setUser} />;
}

function Navigation({ user, setUser }) {
  return <UserMenu user={user} setUser={setUser} />;
}

function UserMenu({ user, setUser }) {
  // ここでやっと実際に使用
  return <div>{user?.name}</div>;
}

Context APIは確かにprops drillingを解決してくれますが、Provider-Consumerパターンの理解が必要で、初心者には少し敷居が高く感じられるかもしれません。

Svelteストアという軽量で直感的なソリューション

こうした複雑さに対して、Svelteはストアという非常にエレガントな解決策を提供しています。Svelteストアの素晴らしさは、そのシンプルさ直感性にあります。

複雑な設定やボイラープレートコードは一切不要で、JavaScriptの基本的な知識があれば、誰でもすぐに理解して使い始められるのです。

課題

コンポーネント間でのデータ共有が煩雑

アプリケーションが成長するにつれて、異なるコンポーネント間でデータを共有する必要性が増してきます。例えば、ヘッダーのユーザー情報、サイドバーの通知数、メインコンテンツのデータなど、これらが別々のコンポーネントで管理されていると、データの同期が非常に困難になります。

従来の方法では、状態を最も上位のコンポーネントで管理し、必要なコンポーネントまでpropsで渡していく必要がありました。しかし、これでは以下のような問題が発生します。

props drilling問題の発生

props drillingは、まさに開発者の生産性を大きく損なう問題です。データを実際に使わないコンポーネントも、下の階層に渡すためだけにpropsを受け取る必要があり、コードの可読性と保守性が著しく低下してしまいます。

また、中間のコンポーネントでpropsの名前を変更したり、新しいpropsを追加したりする際に、すべての階層で修正が必要になるため、変更の影響範囲が広がってしまうのです。

アプリケーションが大きくなるほど状態管理が困難

小規模なアプリケーションでは問題にならなかった状態管理も、機能が増えてコンポーネント数が増加すると、急激に複雑になります。

どこでどの状態が変更されているのか追跡が困難になり、バグの原因究明に多大な時間を要するようになってしまいます。また、同じような状態を複数箇所で管理してしまい、データの整合性が取れなくなるといったことも起こりがちです。

解決策

Svelteのwritableストアによるグローバル状態管理

Svelteのwritableストアは、この状態管理の複雑さを一気に解決してくれる魔法のような機能です。グローバルにアクセス可能な状態を、たった1行で作成できるのです。

writableストアは読み取りも書き込みも可能な状態管理の仕組みで、どのコンポーネントからでも簡単にアクセスできます。props drillingとは完全に無縁の世界が広がります。

readableストアとderivedストアの使い分け

Svelteには用途に応じて3種類のストアが用意されています。

readableストアは、読み取り専用のストアで、時間や外部APIからのデータなど、アプリケーション側では変更しない値を管理する際に使用します。

derivedストアは、他のストアから計算される値を管理するストアです。例えば、複数の商品の合計金額や、ユーザー情報から生成される表示名などに活用できますね。

シンプルなAPIで効率的な状態管理を実現

Svelteストアの最大の魅力は、そのAPIのシンプルさです。覚えるべきメソッドは数個だけで、直感的に理解できる命名がされています。

また、Svelteのリアクティブシステムと完璧に統合されているため、ストアの値が変更されると自動的にコンポーネントが再描画されます。この仕組みにより、開発者は状態の変更通知について考える必要がなくなるのです。

具体例

基本的なwritableストア

それでは、実際にwritableストアを使ったカウンターアプリを作成してみましょう。まずは、ストアファイルを作成することから始めます。

store.jsファイルの作成と設定

最初に、アプリケーション全体で使用するストアを定義するファイルを作成します。

javascript// src/stores.js
import { writable } from 'svelte/store';

// カウンターの初期値を0に設定してwritableストアを作成
export const count = writable(0);

// ユーザー情報を管理するストア
export const user = writable(null);

// アプリケーションの状態を管理するストア
export const isLoading = writable(false);

writable関数は、Svelteから提供される関数で、読み書き可能なストアを作成します。引数には初期値を渡すことができ、この例ではcountを0で初期化しています。

カウンターアプリでの実装例

次に、作成したストアを使ってカウンターコンポーネントを実装してみましょう。

javascript<!-- src/Counter.svelte -->
<script>
  import { count } from './stores.js';

  // ストアの値を増加させる関数
  function increment() {
    count.update(n => n + 1);
  }

  // ストアの値を減少させる関数
  function decrement() {
    count.update(n => n - 1);
  }

  // ストアの値をリセットする関数
  function reset() {
    count.set(0);
  }
</script>

<div class="counter">
  <h1>カウント: {$count}</h1>
  <button on:click={increment}>+1</button>
  <button on:click={decrement}>-1</button>
  <button on:click={reset}>リセット</button>
</div>

ここで注目していただきたいのは、{$count}という記法です。$記法を使うことで、ストアの値に直接アクセスでき、値が変更されると自動的にコンポーネントが再描画されます。

count.update()メソッドは、現在の値を受け取って新しい値を返す関数を引数として受け取ります。count.set()メソッドは、値を直接設定する際に使用します。

readableストア

続いて、readableストアを使った時間表示アプリを作成してみましょう。readableストアは、アプリケーション側では変更できない値を管理する際に使用します。

時間表示アプリでの活用例

現在時刻を1秒ごとに更新する時計アプリを作成してみます。

javascript// src/stores.js
import { readable } from 'svelte/store';

// 現在時刻を毎秒更新するreadableストア
export const time = readable(new Date(), function start(set) {
  // 1秒ごとに現在時刻を更新
  const interval = setInterval(() => {
    set(new Date());
  }, 1000);

  // クリーンアップ関数を返す
  return function stop() {
    clearInterval(interval);
  };
});

readableストアは、第二引数に初期化関数を受け取ります。この関数は、ストアが最初に購読された際に実行され、set関数を引数として受け取ります。

外部データソースとの連携

APIからデータを取得するreadableストアの例も見てみましょう。

javascript// src/stores.js
import { readable } from 'svelte/store';

// ユーザー一覧を取得するreadableストア
export const users = readable([], function start(set) {
  // 初期データの読み込み
  fetch('/api/users')
    .then(response => response.json())
    .then(data => set(data))
    .catch(error => {
      console.error('ユーザーデータの取得に失敗しました:', error);
      set([]);
    });

  // WebSocketで リアルタイム更新を受信
  const ws = new WebSocket('ws://localhost:8080');
  ws.onmessage = (event) => {
    const updatedUsers = JSON.parse(event.data);
    set(updatedUsers);
  };

  return function stop() {
    ws.close();
  };
});

この例では、初期データをAPIから取得し、その後WebSocketを使ってリアルタイムでデータを更新しています。クリーンアップ関数でWebSocket接続を適切に切断することで、メモリリークを防いでいます。

derivedストア

derivedストアは、他のストアの値から計算される値を管理するストアです。ショッピングカートの合計金額を計算する例を見てみましょう。

複数のストアから計算される値の管理

まず、ショッピングカートのアイテムを管理するストアを作成します。

javascript// src/stores.js
import { writable, derived } from 'svelte/store';

// カートアイテムを管理するストア
export const cartItems = writable([
  { id: 1, name: 'ノートPC', price: 80000, quantity: 1 },
  { id: 2, name: 'マウス', price: 2000, quantity: 2 },
  { id: 3, name: 'キーボード', price: 5000, quantity: 1 }
]);

// 消費税率を管理するストア
export const taxRate = writable(0.1);

ショッピングカートの合計金額計算例

次に、これらのストアから合計金額を計算するderivedストアを作成します。

javascript// 小計を計算するderivedストア
export const subtotal = derived(cartItems, $cartItems => {
  return $cartItems.reduce((sum, item) => {
    return sum + (item.price * item.quantity);
  }, 0);
});

// 税額を計算するderivedストア
export const taxAmount = derived(
  [subtotal, taxRate],
  ([$subtotal, $taxRate]) => {
    return Math.round($subtotal * $taxRate);
  }
);

// 最終的な合計金額を計算するderivedストア
export const total = derived(
  [subtotal, taxAmount],
  ([$subtotal, $taxAmount]) => {
    return $subtotal + $taxAmount;
  }
);

derivedストアは、依存するストアの値が変更されると自動的に再計算されます。第一引数には依存するストア(または複数のストアの配列)を、第二引数には計算関数を渡します。

コンポーネントでのストア利用

作成したストアを実際のコンポーネントで使用してみましょう。

$記法による自動購読

最もシンプルで推奨される方法は、$記法を使った自動購読です。

javascript<!-- src/ShoppingCart.svelte -->
<script>
  import { cartItems, subtotal, taxAmount, total } from './stores.js';

  // アイテムの数量を更新する関数
  function updateQuantity(id, newQuantity) {
    cartItems.update(items => {
      return items.map(item => 
        item.id === id 
          ? { ...item, quantity: newQuantity }
          : item
      );
    });
  }

  // アイテムを削除する関数
  function removeItem(id) {
    cartItems.update(items => {
      return items.filter(item => item.id !== id);
    });
  }
</script>

<div class="shopping-cart">
  <h2>ショッピングカート</h2>
  
  {#each $cartItems as item (item.id)}
    <div class="cart-item">
      <span>{item.name}</span>
      <span>¥{item.price.toLocaleString()}</span>
      <input 
        type="number" 
        bind:value={item.quantity}
        on:change={() => updateQuantity(item.id, item.quantity)}
        min="1"
      >
      <button on:click={() => removeItem(item.id)}>削除</button>
    </div>
  {/each}

  <div class="summary">
    <p>小計: ¥{$subtotal.toLocaleString()}</p>
    <p>税額: ¥{$taxAmount.toLocaleString()}</p>
    <p class="total">合計: ¥{$total.toLocaleString()}</p>
  </div>
</div>

$記法を使うことで、ストアの購読と購読解除が自動的に行われるため、メモリリークの心配がありません。

onMountとonDestroyでの手動購読

特定の条件下でのみストアを購読したい場合や、購読のタイミングをより細かく制御したい場合は、手動購読を使用できます。

javascript<!-- src/ManualSubscription.svelte -->
<script>
  import { onMount, onDestroy } from 'svelte';
  import { count } from './stores.js';

  let countValue = 0;
  let unsubscribe;

  onMount(() => {
    // 手動でストアを購読
    unsubscribe = count.subscribe(value => {
      countValue = value;
      console.log('カウントが変更されました:', value);
    });
  });

  onDestroy(() => {
    // コンポーネントが破棄される際に購読を解除
    if (unsubscribe) {
      unsubscribe();
    }
  });
</script>

<div>
  <p>現在のカウント: {countValue}</p>
</div>

この方法では、subscribeメソッドが購読解除関数を返すので、onDestroyライフサイクルフックでそれを呼び出すことで、適切にクリーンアップを行います。

まとめ

Svelteストアの利点と特徴

Svelteストアは、状態管理において以下のような優れた特徴を持っています。

シンプルさ:複雑な設定や大量のボイラープレートコードは不要で、数行のコードで強力な状態管理機能を実現できます。

型安全性:TypeScriptとの相性も抜群で、型安全な状態管理が可能です。

パフォーマンス:Svelteのコンパイル時最適化により、ランタイムオーバーヘッドが最小限に抑えられています。

自動メモリ管理:$記法を使用することで、購読の管理が自動化され、メモリリークの心配がありません。

他のフレームワークとの比較

機能Svelte StoreReact ContextRedux
学習コストの低さ★★★★★★★★☆☆★★☆☆☆
コード量の少なさ★★★★★★★★☆☆★★☆☆☆
パフォーマンス★★★★★★★★☆☆★★★★☆
型安全性★★★★☆★★★☆☆★★★★★

Svelteストアは、特にシンプルさ学習コストの低さにおいて他のフレームワークを大きく上回っています。

導入時の注意点

Svelteストアを導入する際に気をつけるべきポイントをいくつかご紹介します。

ストアの粒度:あまりにも細かくストアを分割しすぎると、かえって管理が複雑になる場合があります。関連性の高いデータはまとめて管理することを検討してください。

初期値の設定:ストアの初期値は慎重に設定しましょう。undefinednullを初期値とする場合、コンポーネント側で適切なガード処理を行う必要があります。

サーバーサイドレンダリング:SvelteKitなどでSSRを使用する場合、サーバーとクライアントでストアの状態が異なることがあります。ハイドレーション時の不整合に注意が必要です。

javascript// SSR対応の例
import { writable } from 'svelte/store';
import { browser } from '$app/environment';

// ブラウザでのみlocalStorageを使用
export const userData = writable(
  browser ? JSON.parse(localStorage.getItem('userData') || 'null') : null
);

Svelteストアは、Web開発における状態管理の概念を根本から変える可能性を秘めた素晴らしい機能です。その直感的なAPIと強力な機能により、開発者はビジネスロジックの実装により多くの時間を割くことができるようになります。

ぜひ、あなたの次のプロジェクトでSvelteストアを試してみてください。きっとその魅力に虜になることでしょう。

関連リンク