T-CREATOR

Svelte ストア速見表:writable/derived/readable/custom の実用スニペット

Svelte ストア速見表:writable/derived/readable/custom の実用スニペット

Svelte でアプリケーションを開発していると、コンポーネント間で状態を共有したい場面に必ず遭遇しますね。そんな時に活躍するのが Svelte の ストア(Store) です。

本記事では、Svelte が提供する 4 種類のストア(writable / derived / readable / custom)について、それぞれの特徴と実用的なコードスニペットを速見表形式でご紹介します。コピー&ペーストで即戦力になるサンプルを豊富に用意しましたので、ぜひ開発にお役立てください。

Svelte ストア速見表

まずは、4 種類のストアの特徴を一覧表で確認しましょう。

ストアタイプ読み取り書き込み主な用途主なメソッド代表的なユースケース
writable自由に読み書きできる基本ストアset(), update(), subscribe()フォーム入力、UI 状態、ユーザー設定
derived×他のストアから計算した値を保持subscribe()合計金額計算、フィルタリング結果、フルネーム生成
readable×読み取り専用(内部ロジックで更新)subscribe()現在時刻、マウス座標、オンライン状態、ウィンドウサイズ
customカスタム独自メソッドを持つ拡張ストア独自メソッド + subscribe()Todo 管理、非同期データ取得、フォームバリデーション

この表を参考に、目的に応じた最適なストアタイプを選択できます。それでは、それぞれのストアについて詳しく見ていきましょう。

背景

Svelte におけるストアの役割

Svelte のコンポーネントは、props や event を使って親子間でデータをやり取りできます。しかし、アプリケーションが大きくなると、深くネストされたコンポーネント間でのデータ共有や、関連性のない複数のコンポーネントで同じ状態を参照したい場合が出てきますね。

このような課題に対して、Svelte は グローバルな状態管理 を可能にする「ストア」という仕組みを提供しています。ストアを使えば、どのコンポーネントからでもアクセス・更新できる共有データを作成できるのです。

以下の図は、ストアを使った状態共有の基本的な流れを示しています。

mermaidflowchart TB
  store[("Svelte ストア<br/>(共有データ)")]
  compA["コンポーネント A"]
  compB["コンポーネント B"]
  compC["コンポーネント C"]

  compA -->|購読| store
  compB -->|購読| store
  compC -->|更新| store
  store -->|変更通知| compA
  store -->|変更通知| compB

図で理解できる要点:

  • ストアは複数のコンポーネントから購読・更新が可能
  • ストアが更新されると、購読しているすべてのコンポーネントに自動通知される
  • コンポーネント間の直接的な依存関係を排除できる

Svelte が提供する 4 つのストア

Svelte の svelte​/​store モジュールには、用途に応じて 4 つのストアタイプが用意されています。

#ストアタイプ用途読み取り書き込み
1writable最も基本的なストア
2derived他のストアから計算した値×
3readable読み取り専用データ×
4custom独自ロジックを持つストアカスタム

それぞれの特性を理解して使い分けることで、効率的な状態管理が実現できます。

課題

よくある状態管理の悩み

Svelte 初心者の方や、他のフレームワークから移行してきた方が直面する課題をいくつかご紹介しましょう。

課題 1:どのストアを使えばいいか分からない

writable、derived、readable、custom の違いが分かりにくく、適切なストアタイプを選択できないという声をよく聞きます。結果として、すべてを writable で実装してしまい、本来は derived で済むケースでも不要な複雑さを生んでしまうことがあります。

課題 2:TypeScript での型定義が曖昧

JavaScript ではなく TypeScript で開発している場合、ストアの型定義をどう書くべきか迷うケースが多いですね。特に複雑なオブジェクト構造を持つストアや、カスタムストアの型定義は初見では難しく感じられます。

課題 3:非同期データの扱い方が不明確

API からデータを取得してストアに格納する、定期的にデータを更新するなど、非同期処理を伴うストアの実装方法が分からないという課題もあります。

以下の図は、これらの課題が発生する典型的な状況を示しています。

mermaidflowchart LR
  dev["開発者"]
  q1["どのストア型を<br/>選ぶべき?"]
  q2["TypeScript の<br/>型定義は?"]
  q3["非同期データは<br/>どう扱う?"]

  dev --> q1
  dev --> q2
  dev --> q3

  q1 -.->|迷い| wrong["すべて writable で<br/>実装してしまう"]
  q2 -.->|迷い| unsafe["型安全性が低下"]
  q3 -.->|迷い| complex["不要に複雑な実装"]

解決策

各ストアの特徴と使い分けのポイント

それぞれのストアタイプには明確な役割があります。以下の判断基準で選択すると良いでしょう。

writable:外部から自由に読み書きしたい場合

ユーザー入力、フォームの状態、UI の開閉状態など、アプリケーションのさまざまな場所から更新される可能性がある値に使います。最も汎用的で、基本的なストアです。

derived:他のストアから計算される値の場合

既存のストアの値を元に計算した結果を保持したい場合に使用します。元のストアが更新されると、自動的に再計算されるため、手動で同期を取る必要がありません。

readable:読み取り専用で外部から変更させたくない場合

時刻、位置情報、WebSocket からのデータストリームなど、特定のロジックでのみ更新され、外部から直接変更されるべきでないデータに適しています。

custom:独自の更新ロジックを実装したい場合

標準のストアでは実現できない特殊な振る舞いが必要な場合に、カスタムストアを作成します。内部的には writable を使いつつ、外部には独自のメソッドだけを公開できます。

以下の図は、ストアタイプ選択のフローチャートです。

mermaidflowchart TD
  start["ストアが必要"]
  q1{"外部から<br/>書き込みが必要?"}
  q2{"他のストアから<br/>派生した値?"}
  q3{"独自メソッドが<br/>必要?"}

  start --> q1
  q1 -->|Yes| q2
  q1 -->|No| readable["readable を使用"]
  q2 -->|Yes| derived["derived を使用"]
  q2 -->|No| q3
  q3 -->|Yes| custom["custom を使用"]
  q3 -->|No| writable["writable を使用"]

図で理解できる要点:

  • 書き込み不要なら readable を検討
  • 他のストアに依存するなら derived が最適
  • 独自メソッドが必要なら custom で拡張

TypeScript での型安全な実装

TypeScript を使用する場合、ストアにも適切な型を付けることで、開発時の補完やエラー検出が向上します。基本的には、ジェネリクス型パラメータでストアの値の型を指定します。

非同期処理のベストプラクティス

API 呼び出しなどの非同期処理は、カスタムストアまたは readable で実装するのが一般的です。ローディング状態やエラー状態も一緒に管理すると、UI の実装がスムーズになります。

具体例

writable ストアの実用スニペット

writable は最も基本的なストアです。初期値を指定して作成し、setupdate で値を変更できます。

インポート文

typescriptimport { writable } from 'svelte/store';

基本的な writable ストアの作成

最もシンプルな writable ストアの例です。カウンターの値を保持します。

typescript// カウンターストアを作成(初期値: 0)
export const count = writable(0);

TypeScript での型定義付き writable

TypeScript を使う場合は、ジェネリクスで型を指定すると型安全になります。

typescript// 数値型のストア
export const count = writable<number>(0);

// 文字列型のストア
export const username = writable<string>('');

// オブジェクト型のストア(インターフェース定義)
interface User {
  id: number;
  name: string;
  email: string;
}

export const currentUser = writable<User | null>(null);

コンポーネントでの購読(自動購読構文)

Svelte では、ストア名の前に $ を付けるだけで自動購読できます。これにより、subscribe/unsubscribe を手動で管理する必要がありません。

svelte<script>
  import { count } from './stores';

  // $count で値を参照(自動購読)
  // コンポーネント破棄時に自動的に購読解除される
</script>

<p>現在のカウント: {$count}</p>
<button on:click={() => count.update(n => n + 1)}>
  +1
</button>

上記のコードでは、$count という構文を使うことで、ストアの値を直接テンプレート内で参照できます。値が変更されると、自動的に再レンダリングされます。

set メソッドでの更新

set メソッドは、ストアの値を直接置き換えます。

typescriptimport { count } from './stores';

// 値を 10 にセット
count.set(10);

// オブジェクトストアの場合
currentUser.set({
  id: 1,
  name: '田中太郎',
  email: 'tanaka@example.com',
});

update メソッドでの更新

update メソッドは、現在の値を元に新しい値を計算する場合に便利です。

typescriptimport { count } from './stores';

// 現在の値に 1 を足す
count.update((n) => n + 1);

// 現在の値を 2 倍にする
count.update((n) => n * 2);

配列を持つ writable ストアの操作

配列を管理するストアでは、update メソッドを使って要素の追加・削除を行います。

typescriptinterface Todo {
  id: number;
  text: string;
  done: boolean;
}

export const todos = writable<Todo[]>([]);

配列ストアへの要素追加と削除の実装例です。

typescript// 新しい Todo を追加
todos.update((items) => [
  ...items,
  { id: Date.now(), text: '買い物に行く', done: false },
]);

// ID を指定して削除
todos.update((items) =>
  items.filter((item) => item.id !== targetId)
);

// ID を指定して done を切り替え
todos.update((items) =>
  items.map((item) =>
    item.id === targetId
      ? { ...item, done: !item.done }
      : item
  )
);

localStorage との連携

ストアの値を localStorage に永続化する実装例です。ページをリロードしても値が保持されます。

typescriptimport { writable } from 'svelte/store';

// localStorage から初期値を読み込む関数
function createPersistedStore<T>(
  key: string,
  initialValue: T
) {
  // ブラウザ環境かチェック(SSR 対応)
  const isBrowser = typeof window !== 'undefined';

  // localStorage から値を取得
  const stored = isBrowser
    ? localStorage.getItem(key)
    : null;
  const initial = stored
    ? JSON.parse(stored)
    : initialValue;

  const store = writable<T>(initial);

  return store;
}

ストアの変更を localStorage に自動保存する仕組みです。

typescript// subscribe で変更を監視し、localStorage に保存
if (typeof window !== 'undefined') {
  store.subscribe((value) => {
    localStorage.setItem(key, JSON.stringify(value));
  });
}

return store;

使用例は以下の通りです。

typescript// 使用例
export const theme = createPersistedStore<'light' | 'dark'>(
  'theme',
  'light'
);

derived ストアの実用スニペット

derived ストアは、他のストアから計算された値を保持します。元のストアが更新されると、自動的に再計算されます。

インポート文

typescriptimport { writable, derived } from 'svelte/store';

単一ストアからの派生

1 つのストアを元に、新しい値を計算する例です。

typescript// 元となるストア
export const count = writable(0);

// count を 2 倍にした値を持つストア
export const doubled = derived(
  count,
  ($count) => $count * 2
);

複数ストアからの派生

複数のストアの値を組み合わせて、新しい値を計算できます。

typescriptexport const firstName = writable('太郎');
export const lastName = writable('田中');

// 2 つのストアから fullName を生成
export const fullName = derived(
  [firstName, lastName],
  ([$firstName, $lastName]) => `${$lastName} ${$firstName}`
);

この例では、姓と名を別々のストアで管理し、フルネームは自動的に計算されます。

非同期処理を含む derived

derived の第 3 引数に初期値を指定し、第 2 引数の関数で set を呼ぶことで、非同期処理にも対応できます。

typescriptimport { writable, derived } from 'svelte/store';

export const userId = writable<number | null>(null);

ユーザー ID が変更されたら、API からユーザー情報を取得する例です。

typescript// userId が変更されたら API から情報を取得
export const userInfo = derived(
  userId,
  ($userId, set) => {
    if ($userId === null) {
      set(null);
      return;
    }

    // API 呼び出し
    fetch(`/api/users/${$userId}`)
      .then((res) => res.json())
      .then((data) => set(data))
      .catch((err) => {
        console.error(err);
        set(null);
      });
  },
  null // 初期値
);

フィルタリング・検索機能の実装

リストデータと検索キーワードから、フィルタリング結果を返す derived の例です。

typescriptinterface Product {
  id: number;
  name: string;
  price: number;
}

export const products = writable<Product[]>([
  { id: 1, name: 'ノートPC', price: 120000 },
  { id: 2, name: 'マウス', price: 3000 },
  { id: 3, name: 'キーボード', price: 8000 },
]);

export const searchQuery = writable('');

検索クエリに基づいて商品をフィルタリングする derived ストアです。

typescript// 検索結果を返す derived ストア
export const filteredProducts = derived(
  [products, searchQuery],
  ([$products, $searchQuery]) => {
    if (!$searchQuery) return $products;

    const query = $searchQuery.toLowerCase();
    return $products.filter((p) =>
      p.name.toLowerCase().includes(query)
    );
  }
);

集計・統計値の計算

ショッピングカートの合計金額を計算する例です。

typescriptinterface CartItem {
  productId: number;
  quantity: number;
  price: number;
}

export const cart = writable<CartItem[]>([]);

// カート内の合計金額を計算
export const totalPrice = derived(cart, ($cart) =>
  $cart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )
);

// カート内のアイテム数を計算
export const totalItems = derived(cart, ($cart) =>
  $cart.reduce((sum, item) => sum + item.quantity, 0)
);

readable ストアの実用スニペット

readable は読み取り専用のストアで、外部から値を変更できません。初期化時に定義したロジックでのみ値が更新されます。

インポート文

typescriptimport { readable } from 'svelte/store';

現在時刻を提供するストア

1 秒ごとに現在時刻を更新する readable ストアの例です。

typescript// 1 秒ごとに更新される現在時刻ストア
export const currentTime = readable(new Date(), (set) => {
  const interval = setInterval(() => {
    set(new Date());
  }, 1000);

  // クリーンアップ関数(購読解除時に実行)
  return () => clearInterval(interval);
});

第 2 引数の関数は、最初の購読者が現れたときに実行され、戻り値の関数は最後の購読者がいなくなったときに実行されます。

マウス座標を追跡するストア

ブラウザのマウス座標をリアルタイムで追跡する例です。

typescriptinterface MousePosition {
  x: number;
  y: number;
}

export const mousePosition = readable<MousePosition>(
  { x: 0, y: 0 },
  (set) => {
    function handleMouseMove(event: MouseEvent) {
      set({ x: event.clientX, y: event.clientY });
    }

    // イベントリスナーを登録
    window.addEventListener('mousemove', handleMouseMove);

    // クリーンアップ
    return () => {
      window.removeEventListener(
        'mousemove',
        handleMouseMove
      );
    };
  }
);

オンライン・オフライン状態の監視

ネットワークの接続状態を監視するストアです。

typescriptexport const isOnline = readable(
  typeof navigator !== 'undefined'
    ? navigator.onLine
    : true,
  (set) => {
    function handleOnline() {
      set(true);
    }

    function handleOffline() {
      set(false);
    }

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }
);

ウィンドウサイズの監視

ブラウザのウィンドウサイズ変更を検知するストアです。レスポンシブデザインの実装に役立ちます。

typescriptinterface WindowSize {
  width: number;
  height: number;
}

export const windowSize = readable<WindowSize>(
  { width: 0, height: 0 },
  (set) => {
    function updateSize() {
      set({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // 初回実行
    updateSize();

    // リサイズイベントを監視
    window.addEventListener('resize', updateSize);

    return () => {
      window.removeEventListener('resize', updateSize);
    };
  }
);

custom ストアの実用スニペット

カスタムストアは、writable を内部で使いつつ、独自のメソッドを外部に公開することで、ストアの操作を制限・拡張できます。

インポート文

typescriptimport { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';

カウンターストア(increment/decrement メソッド付き)

set や update を直接公開せず、専用メソッドだけを提供する例です。

typescriptfunction createCounter(initialValue = 0) {
  const { subscribe, set, update } = writable(initialValue);

  return {
    subscribe, // subscribe は公開(購読に必要)
    increment: () => update((n) => n + 1),
    decrement: () => update((n) => n - 1),
    reset: () => set(initialValue),
  };
}

export const counter = createCounter(0);

この実装により、外部からは counter.set(100) のような直接的な操作ができず、定義されたメソッドのみ使用できます。

非同期データ取得ストア(loading/error 状態付き)

API からデータを取得し、ローディング状態とエラー状態も管理するカスタムストアです。

typescriptinterface AsyncState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function createAsyncStore<T>(fetchFn: () => Promise<T>) {
  const { subscribe, set } = writable<AsyncState<T>>({
    data: null,
    loading: false,
    error: null,
  });

  return {
    subscribe,
    fetch: async () => {
      // ローディング開始
      set({ data: null, loading: true, error: null });

      try {
        const data = await fetchFn();
        set({ data, loading: false, error: null });
      } catch (err) {
        set({
          data: null,
          loading: false,
          error:
            err instanceof Error
              ? err.message
              : 'エラーが発生しました',
        });
      }
    },
  };
}

使用例は以下の通りです。

typescript// 使用例
export const userStore = createAsyncStore<User>(() =>
  fetch('/api/user').then((res) => res.json())
);

// コンポーネントから呼び出し
// userStore.fetch();

Todo リスト管理ストア

Todo アイテムの追加・削除・完了状態の切り替えなど、複数の操作を持つカスタムストアです。

typescriptinterface Todo {
  id: number;
  text: string;
  done: boolean;
}

function createTodoStore() {
  const { subscribe, update } = writable<Todo[]>([]);

  return {
    subscribe,
    add: (text: string) => {
      update((items) => [
        ...items,
        { id: Date.now(), text, done: false },
      ]);
    },
    remove: (id: number) => {
      update((items) =>
        items.filter((item) => item.id !== id)
      );
    },
    toggle: (id: number) => {
      update((items) =>
        items.map((item) =>
          item.id === id
            ? { ...item, done: !item.done }
            : item
        )
      );
    },
    clearCompleted: () => {
      update((items) => items.filter((item) => !item.done));
    },
  };
}

export const todos = createTodoStore();

フォーム管理ストア(バリデーション付き)

フォームの値と検証エラーを一緒に管理するストアです。

typescriptinterface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
}

function createFormStore() {
  const { subscribe, update } = writable<FormState>({
    values: {},
    errors: {},
    touched: {},
  });

  return {
    subscribe,
    setValue: (field: string, value: string) => {
      update((state) => ({
        ...state,
        values: { ...state.values, [field]: value },
      }));
    },
    setError: (field: string, error: string) => {
      update((state) => ({
        ...state,
        errors: { ...state.errors, [field]: error },
      }));
    },
    setTouched: (field: string) => {
      update((state) => ({
        ...state,
        touched: { ...state.touched, [field]: true },
      }));
    },
    reset: () => {
      update(() => ({
        values: {},
        errors: {},
        touched: {},
      }));
    },
  };
}

export const form = createFormStore();

以下の図は、カスタムストアの内部構造と外部インターフェースの関係を示しています。

mermaidflowchart TB
  subgraph custom["カスタムストア"]
    writable_internal["writable<br/>(内部実装)"]
    methods["独自メソッド<br/>(increment, fetch など)"]
  end

  component["コンポーネント"]

  component -->|"subscribe のみ公開"| custom
  component -->|"独自メソッド呼び出し"| methods
  methods -->|"内部で update/set"| writable_internal
  writable_internal -->|"変更通知"| component

図で理解できる要点:

  • カスタムストアは writable を内部に隠蔽
  • 外部には subscribe と独自メソッドのみを公開
  • set/update を直接呼べないため、意図しない変更を防止

ストアの購読と購読解除

手動購読(subscribe メソッド)

自動購読構文($)が使えない場合は、subscribe メソッドを使います。

typescriptimport { count } from './stores';
import { onDestroy } from 'svelte';

let currentCount: number;

// subscribe は購読解除関数を返す
const unsubscribe = count.subscribe((value) => {
  currentCount = value;
});

// コンポーネント破棄時に購読解除
onDestroy(() => {
  unsubscribe();
});

get 関数で一時的に値を取得

購読せずに、現在の値だけを取得したい場合は get 関数を使います。

typescriptimport { get } from 'svelte/store';
import { count } from './stores';

// 現在の値を取得(購読はしない)
const currentValue = get(count);
console.log(currentValue);

ただし、get は値の変更を監視しないため、リアクティブな処理には向きません。主にイベントハンドラ内での一時的な参照に使用します。

まとめ

本記事では、Svelte の 4 種類のストア(writable / derived / readable / custom)について、それぞれの特徴と実用的なコードスニペットをご紹介しました。

writable は最も基本的なストアで、自由に読み書きできます。フォームの入力値や UI の状態管理など、幅広い用途に使えますね。

derived は他のストアから計算された値を保持します。元のストアが更新されると自動的に再計算されるため、手動での同期が不要になり、コードがシンプルになります。

readable は読み取り専用ストアで、時刻やマウス座標など、特定のロジックでのみ更新されるデータに適しています。外部から誤って変更されることを防げます。

custom は writable をベースに独自のメソッドを実装したストアです。ドメイン固有の操作をカプセル化でき、保守性の高いコードが書けるでしょう。

それぞれのストアには明確な役割があり、適切に使い分けることで、Svelte アプリケーションの状態管理がより効率的で読みやすくなります。ぜひ本記事のスニペットをコピーして、実際のプロジェクトでお試しください。

Svelte のストアは軽量でシンプルながら、非常に強力な機能です。この速見表が、皆さまの開発を加速させる一助となれば幸いです。

関連リンク