T-CREATOR

SolidJS コンポーネント間通信チート:Context・イベント・store の選択早見

SolidJS コンポーネント間通信チート:Context・イベント・store の選択早見

SolidJS でアプリケーションを構築する際、コンポーネント間でデータをやり取りする方法が複数あって、どれを選べばよいのか迷ってしまうことはありませんか。 Context を使うべきか、カスタムイベントで済ませるべきか、それとも Store を導入すべきか。それぞれに得意な場面があり、適切に使い分けることでコードの可読性や保守性が大きく向上します。

本記事では、SolidJS における 3 つの主要なコンポーネント間通信手法(Context API、カスタムイベント、Store)の特徴と選択基準を、早見表と具体例を交えて解説していきます。

早見表:通信手法の選択基準

各手法の特徴を一目で把握できるよう、比較表を用意しました。

#手法適した用途スコープパフォーマンス学習コスト典型的な利用例
1Context APIツリー内での依存性注入ローカル(Provider 配下)★★★★★★★テーマ設定、認証情報、ルート設定
2カスタムイベント疎結合な通知・イベント駆動グローバル or ローカル★★★★★ボタンクリック通知、モーダル制御
3Store (createStore)グローバルな状態管理グローバル★★★★★★★★★ユーザーデータ、カート情報、アプリ全体の状態

凡例: ★ が多いほど優れている、または高度

#手法メリットデメリット
1Context APIツリー構造に自然にフィット、型安全Provider のネスト地獄になる可能性
2カスタムイベントコンポーネント間の結合度が低い、柔軟型安全性が弱い、デバッグが難しい
3Store細粒度のリアクティビティ、パフォーマンス最高学習コストが高い、過度な利用で複雑化

背景

SolidJS のリアクティビティシステム

SolidJS は、細粒度のリアクティビティシステムを採用しているモダンな JavaScript フレームワークです。 React や Vue と異なり、仮想 DOM を使わず、リアクティブな値の変更を直接 DOM に反映させます。

このアーキテクチャにより、高速なレンダリングと最小限の再描画が実現されていますが、一方でコンポーネント間通信の手法も独自の特性を持ちます。

以下の図は、SolidJS アプリケーションにおけるデータフローの全体像を示しています。

mermaidflowchart TB
    subgraph app["アプリケーション全体"]
        root["ルートコンポーネント"]

        subgraph context_scope["Context スコープ"]
            provider["Context Provider"]
            child1["子コンポーネントA"]
            child2["子コンポーネントB"]
        end

        subgraph global["グローバル領域"]
            store["Store<br/>(グローバル状態)"]
            events["CustomEvent<br/>(イベントバス)"]
        end
    end

    root --> provider
    provider -->|useContext| child1
    provider -->|useContext| child2

    child1 -.->|dispatch| events
    events -.->|listen| child2

    child1 -->|setStore| store
    child2 -->|読み取り| store

この図から分かるように、Context、イベント、Store はそれぞれ異なるスコープと通信経路を持っています。

各手法の基本的な位置づけ

SolidJS では以下の 3 つが主要なコンポーネント間通信手法として提供されています。

Context APIは、React と同様に依存性注入(DI)パターンを実現します。 親コンポーネントから子孫コンポーネントへデータを「注入」する形で、props のバケツリレーを避けられます。

カスタムイベントは、ブラウザ標準の CustomEvent を活用した疎結合な通信方法です。 イベント駆動アーキテクチャを採用したい場合に有効で、特定のコンポーネントに依存しない設計が可能になります。

Storeは、SolidJS 独自の強力な状態管理ソリューションです。 細粒度のリアクティビティを活かし、大規模なアプリケーション状態を効率的に管理できます。

課題

どの手法を選ぶべきか判断が難しい

開発者が直面する最大の課題は、「どの場面でどの手法を使うべきか」の判断基準が曖昧なことです。

例えば、認証情報をアプリ全体で共有したい場合、Context で実装するべきでしょうか、それとも Store を使うべきでしょうか。 また、モーダルの開閉をトリガーしたい場合、イベントと Context のどちらが適しているでしょうか。

以下は、よくある判断の迷いを図示したものです。

mermaidflowchart TD
    start["コンポーネント間通信が必要"]

    q1{"データは<br/>グローバルか?"}
    q2{"頻繁に<br/>更新されるか?"}
    q3{"ツリー構造に<br/>沿っているか?"}
    q4{"イベント駆動<br/>の設計か?"}

    ans_store["Store を選択"]
    ans_context["Context を選択"]
    ans_event["CustomEvent を選択"]
    ans_props["Props で十分"]

    start --> q1
    q1 -->|はい| q2
    q1 -->|いいえ| q3

    q2 -->|はい| ans_store
    q2 -->|いいえ| q4

    q3 -->|はい| ans_context
    q3 -->|いいえ| q4

    q4 -->|はい| ans_event
    q4 -->|いいえ| ans_props

この判断フローを参考にすることで、適切な手法を選びやすくなります。

誤った選択がもたらす問題

不適切な手法を選択すると、以下のような問題が発生します。

過度な Context 使用による問題として、Provider が深くネストし、コードの可読性が低下します。 また、Context の値が変更されると、その Context を利用するすべての子孫コンポーネントが再評価される可能性があり、パフォーマンスに影響を及ぼすことがあります。

カスタムイベントの乱用は、イベント名の管理が煩雑になり、どのコンポーネントがどのイベントをリスンしているのか追跡しづらくなります。 型安全性も失われがちで、実行時エラーのリスクが高まるでしょう。

Store の過剰導入では、本来ローカルで管理すべき状態までグローバル化してしまい、アプリケーション全体の複雑性が増大します。 小規模な機能にまで Store を使うと、かえってコードが冗長になってしまいます。

解決策

Context API:ツリー内での依存性注入

Context API は、コンポーネントツリー内での依存性注入に最適です。

Context API の特徴

Context は以下の特徴を持っています。

  • スコープが Provider 配下に限定される
  • 型安全性が高い(TypeScript との相性が良い)
  • ツリー構造に沿った自然なデータフロー

Context API を選ぶべき場面

以下のような場面で Context を選択しましょう。

#場面理由
1テーマ設定(ダークモード/ライトモード)アプリ全体で共有するが、変更頻度は低い
2認証情報(ユーザー情報、トークン)セキュアに特定スコープで共有したい
3ルーティング情報ページ単位でのコンテキストが必要
4多言語対応(i18n)言語設定を子孫コンポーネントで参照

Context API の実装パターン

Context の基本的な実装方法を見ていきましょう。

カスタムイベント:疎結合なイベント駆動通信

カスタムイベントは、コンポーネント間の結合度を下げたい場合に有効です。

カスタムイベントの特徴

以下のような特性があります。

  • 送信側と受信側が互いを知る必要がない
  • グローバルにもローカルにも使える
  • ブラウザ標準機能を活用

カスタムイベントを選ぶべき場面

イベント駆動が適している場面を整理します。

#場面理由
1モーダルやダイアログの制御開閉イベントを疎結合に通知
2通知・トースト表示任意の場所から通知を発火
3アナリティクスイベント送信ビジネスロジックと分離したい
4画面遷移のトリガールーティング制御を柔軟に

カスタムイベントの実装パターン

イベントベースの通信を実装する方法を解説します。

Store:グローバルな状態管理

Store は、アプリケーション全体で共有する複雑な状態を管理するのに最適です。

Store の特徴

SolidJS の Store は非常に強力です。

  • 細粒度のリアクティビティによる高いパフォーマンス
  • ネストしたオブジェクトもリアクティブに扱える
  • Immer のような記述が可能(produce ユーティリティ)

Store を選ぶべき場面

以下のケースで Store が威力を発揮します。

#場面理由
1ユーザーデータ(プロフィール、設定)複数画面で参照・更新が必要
2ショッピングカート商品追加・削除・数量変更が頻繁
3フォームの複雑な状態管理多段フォームや検証状態の管理
4リアルタイムデータ(チャット、通知)頻繁な更新とリアクティブな反映

Store の実装パターン

Store を使った状態管理の実装を見ていきます。

具体例

それでは、各手法の具体的な実装コードを見ていきましょう。 実際のユースケースに基づいたサンプルコードを提示します。

Context API の実装例

テーマ設定を管理する Context の実装を段階的に説明します。

ステップ 1:Context の型定義

まず、Context で扱うデータの型を定義します。

typescript// theme-context.ts

// テーマの種類を定義
type Theme = 'light' | 'dark';

// Contextで提供する値の型
interface ThemeContextValue {
  theme: () => Theme; // 現在のテーマを取得
  toggleTheme: () => void; // テーマを切り替える関数
}

型を明確にすることで、TypeScript の恩恵を最大限受けられます。

ステップ 2:Context の作成

次に、createContext で Context オブジェクトを生成します。

typescriptimport { createContext } from 'solid-js';

// ThemeContextを作成(初期値はundefined)
export const ThemeContext =
  createContext<ThemeContextValue>();

初期値を undefined にすることで、Provider の外で使用した際にエラーを検出できます。

ステップ 3:Provider コンポーネントの実装

Provider コンポーネントでは、テーマの状態とロジックを管理します。

typescriptimport { createSignal, JSX } from 'solid-js';

interface ThemeProviderProps {
  children: JSX.Element;
}

export function ThemeProvider(props: ThemeProviderProps) {
  // テーマの状態を管理するSignal
  const [theme, setTheme] = createSignal<Theme>('light');

  // テーマを切り替える関数
  const toggleTheme = () => {
    setTheme((prev) =>
      prev === 'light' ? 'dark' : 'light'
    );
  };

  // Contextに提供する値
  const value: ThemeContextValue = {
    theme,
    toggleTheme,
  };

  return (
    <ThemeContext.Provider value={value}>
      {props.children}
    </ThemeContext.Provider>
  );
}

createSignal でリアクティブな状態を作成し、Provider で配下のコンポーネントに提供しています。

ステップ 4:カスタムフックの作成

Context を使いやすくするため、カスタムフックを用意します。

typescriptimport { useContext } from 'solid-js';

// ThemeContextを安全に取得するフック
export function useTheme() {
  const context = useContext(ThemeContext);

  // Providerの外で使われた場合はエラー
  if (!context) {
    throw new Error(
      'useTheme must be used within ThemeProvider'
    );
  }

  return context;
}

このフックにより、型安全かつ簡潔に Context を利用できます。

ステップ 5:コンポーネントでの利用

実際にコンポーネントで ThemeContext を使ってみましょう。

typescriptimport { Component } from 'solid-js';
import { useTheme } from './theme-context';

const ThemeToggleButton: Component = () => {
  // カスタムフックでContextを取得
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      現在のテーマ: {theme()}
      (クリックで切り替え)
    </button>
  );
};

useTheme フックを呼ぶだけで、簡単にテーマ情報にアクセスできます。

ステップ 6:アプリケーション全体への適用

最後に、ルートコンポーネントで Provider を配置します。

typescriptimport { Component } from 'solid-js';
import { ThemeProvider } from './theme-context';
import ThemeToggleButton from './ThemeToggleButton';

const App: Component = () => {
  return (
    <ThemeProvider>
      <div>
        <h1>SolidJS テーマ切り替えアプリ</h1>
        <ThemeToggleButton />
      </div>
    </ThemeProvider>
  );
};

ThemeProvider で囲んだ配下のすべてのコンポーネントで useTheme が利用可能になります。

カスタムイベントの実装例

モーダルダイアログの制御をカスタムイベントで実装してみましょう。

ステップ 1:イベント名の定義

イベント名を定数として定義し、タイポを防ぎます。

typescript// events.ts

// モーダル関連のイベント名を定数化
export const MODAL_EVENTS = {
  OPEN: 'modal:open',
  CLOSE: 'modal:close',
} as const;

// イベントのペイロード型
export interface ModalEventDetail {
  modalId: string; // モーダルのID
  data?: Record<string, unknown>; // 任意のデータ
}

定数化することで、IDE の補完も効き、保守性が向上します。

ステップ 2:イベント発火用のヘルパー関数

イベントをディスパッチするヘルパー関数を作成します。

typescript// modal-events.ts

export function openModal(
  modalId: string,
  data?: Record<string, unknown>
) {
  // CustomEventを作成
  const event = new CustomEvent(MODAL_EVENTS.OPEN, {
    detail: { modalId, data },
  });

  // グローバルにディスパッチ
  window.dispatchEvent(event);
}

export function closeModal(modalId: string) {
  const event = new CustomEvent(MODAL_EVENTS.CLOSE, {
    detail: { modalId },
  });

  window.dispatchEvent(event);
}

これらのヘルパー関数により、どこからでも簡単にモーダルを制御できます。

ステップ 3:イベントリスナーの実装

モーダルコンポーネント側でイベントをリッスンします。

typescriptimport {
  createSignal,
  onMount,
  onCleanup,
  Component,
} from 'solid-js';

interface ModalProps {
  modalId: string;
}

const Modal: Component<ModalProps> = (props) => {
  // モーダルの開閉状態
  const [isOpen, setIsOpen] = createSignal(false);

  // イベントハンドラー:モーダルを開く
  const handleOpen = (event: Event) => {
    const customEvent =
      event as CustomEvent<ModalEventDetail>;

    // 自分宛のイベントかチェック
    if (customEvent.detail.modalId === props.modalId) {
      setIsOpen(true);
    }
  };

  // イベントハンドラー:モーダルを閉じる
  const handleClose = (event: Event) => {
    const customEvent =
      event as CustomEvent<ModalEventDetail>;

    if (customEvent.detail.modalId === props.modalId) {
      setIsOpen(false);
    }
  };

  return (
    // JSXの実装は省略
    <div>Modal Content</div>
  );
};

CustomEvent の detail プロパティから必要な情報を取得しています。

ステップ 4:ライフサイクルでのイベント登録・解除

onMount と onCleanup でイベントリスナーを適切に管理します。

typescriptconst Modal: Component<ModalProps> = (props) => {
  const [isOpen, setIsOpen] = createSignal(false);

  // (前述のhandleOpen, handleCloseは省略)

  onMount(() => {
    // コンポーネントマウント時にリスナー登録
    window.addEventListener(MODAL_EVENTS.OPEN, handleOpen);
    window.addEventListener(
      MODAL_EVENTS.CLOSE,
      handleClose
    );
  });

  onCleanup(() => {
    // コンポーネント破棄時にリスナー解除
    window.removeEventListener(
      MODAL_EVENTS.OPEN,
      handleOpen
    );
    window.removeEventListener(
      MODAL_EVENTS.CLOSE,
      handleClose
    );
  });

  return (
    <div class={isOpen() ? 'modal-open' : 'modal-closed'}>
      {/* モーダルの中身 */}
    </div>
  );
};

onCleanup でリスナーを解除することで、メモリリークを防ぎます。

ステップ 5:イベント発火側の実装

別のコンポーネントからモーダルを開く例です。

typescriptimport { Component } from 'solid-js';
import { openModal } from './modal-events';

const TriggerButton: Component = () => {
  // ボタンクリック時にモーダルを開く
  const handleClick = () => {
    openModal('user-settings', {
      userId: 123,
      tab: 'profile',
    });
  };

  return <button onClick={handleClick}>設定を開く</button>;
};

openModal ヘルパーを使うだけで、モーダルコンポーネントと直接依存せずに制御できます。

Store の実装例

ショッピングカートを Store で管理する実装を見ていきます。

ステップ 1:Store の型定義

カートで扱うデータの型を定義します。

typescript// cart-store.ts

// 商品アイテムの型
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

// カート全体の型
export interface CartStore {
  items: CartItem[];
  totalPrice: number;
}

明確な型定義により、開発時のミスを防げます。

ステップ 2:Store の作成と初期化

createStore でリアクティブなストアを作成します。

typescriptimport { createStore } from 'solid-js/store';

// 初期状態
const initialState: CartStore = {
  items: [],
  totalPrice: 0,
};

// Storeの作成
export const [cartStore, setCartStore] =
  createStore<CartStore>(initialState);

createStore は配列を返し、第 1 要素が読み取り専用の Store、第 2 要素が更新関数です。

ステップ 3:カート操作のアクション関数

商品追加・削除などのロジックを関数として実装します。

typescript// 商品を追加
export function addToCart(
  item: Omit<CartItem, 'quantity'>
) {
  // 既存の商品か確認
  const existingIndex = cartStore.items.findIndex(
    (i) => i.id === item.id
  );

  if (existingIndex >= 0) {
    // 既存商品の数量を増やす
    setCartStore(
      'items',
      existingIndex,
      'quantity',
      (q) => q + 1
    );
  } else {
    // 新規商品を追加
    setCartStore('items', (items) => [
      ...items,
      { ...item, quantity: 1 },
    ]);
  }

  // 合計金額を再計算
  updateTotalPrice();
}

SolidJS の setStore は、パス指定で深い階層も簡単に更新できます。

ステップ 4:商品削除と数量変更

削除と数量変更のアクションも実装します。

typescript// 商品を削除
export function removeFromCart(itemId: string) {
  setCartStore('items', (items) =>
    items.filter((item) => item.id !== itemId)
  );
  updateTotalPrice();
}

// 数量を変更
export function updateQuantity(
  itemId: string,
  newQuantity: number
) {
  if (newQuantity <= 0) {
    // 数量が0以下なら削除
    removeFromCart(itemId);
    return;
  }

  const index = cartStore.items.findIndex(
    (i) => i.id === itemId
  );
  if (index >= 0) {
    setCartStore('items', index, 'quantity', newQuantity);
    updateTotalPrice();
  }
}

数量が 0 以下になったら自動的に削除するロジックを入れています。

ステップ 5:合計金額の計算

合計金額を計算する補助関数です。

typescript// 合計金額を更新
function updateTotalPrice() {
  const total = cartStore.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  setCartStore('totalPrice', total);
}

商品の追加・削除・数量変更の度に呼び出され、常に最新の合計金額を保持します。

ステップ 6:コンポーネントでの利用

カート情報を表示するコンポーネントを作成します。

typescriptimport { Component, For } from 'solid-js';
import {
  cartStore,
  removeFromCart,
  updateQuantity,
} from './cart-store';

const CartView: Component = () => {
  return (
    <div class='cart'>
      <h2>ショッピングカート</h2>

      {/* カート内の商品をリスト表示 */}
      <For each={cartStore.items}>
        {(item) => (
          <div class='cart-item'>
            <span>{item.name}</span>
            <span>¥{item.price}</span>

            {/* 数量変更 */}
            <input
              type='number'
              value={item.quantity}
              onInput={(e) => {
                const value = parseInt(
                  e.currentTarget.value
                );
                updateQuantity(item.id, value);
              }}
            />

            {/* 削除ボタン */}
            <button onClick={() => removeFromCart(item.id)}>
              削除
            </button>
          </div>
        )}
      </For>

      {/* 合計金額 */}
      <div class='total'>合計: ¥{cartStore.totalPrice}</div>
    </div>
  );
};

cartStore を直接参照するだけで、リアクティブに UI が更新されます。

ステップ 7:別のコンポーネントからカートに追加

商品リストからカートに追加する例です。

typescriptimport { Component } from 'solid-js';
import { addToCart } from './cart-store';

interface ProductCardProps {
  id: string;
  name: string;
  price: number;
}

const ProductCard: Component<ProductCardProps> = (
  props
) => {
  const handleAddToCart = () => {
    addToCart({
      id: props.id,
      name: props.name,
      price: props.price,
    });
  };

  return (
    <div class='product-card'>
      <h3>{props.name}</h3>
      <p>¥{props.price}</p>
      <button onClick={handleAddToCart}>
        カートに追加
      </button>
    </div>
  );
};

どのコンポーネントからでも addToCart を呼ぶだけで、グローバルなカート状態が更新されます。

実装パターンの比較図

3 つの手法を実装した際の構造を図で比較してみましょう。

mermaidflowchart TB
    subgraph context_pattern["Context パターン"]
        cp1["ThemeProvider"]
        cp2["Button Component"]
        cp3["Header Component"]

        cp1 -->|useContext| cp2
        cp1 -->|useContext| cp3
    end

    subgraph event_pattern["Event パターン"]
        ep1["Trigger Component"]
        ep2["Modal Component"]
        ep3["window (EventTarget)"]

        ep1 -.->|dispatch| ep3
        ep3 -.->|listen| ep2
    end

    subgraph store_pattern["Store パターン"]
        sp1["Global Store"]
        sp2["Product List"]
        sp3["Cart View"]

        sp2 -->|addToCart| sp1
        sp1 -->|reactive| sp3
    end

それぞれのデータフローの違いが明確になりますね。

まとめ

SolidJS でのコンポーネント間通信には、Context API、カスタムイベント、Store という 3 つの主要な手法があります。

Context APIは、コンポーネントツリー内での依存性注入に適しており、テーマ設定や認証情報など、変更頻度が低く、特定スコープで共有したいデータに最適です。 型安全性が高く、Provider の配下で自然なデータフローを実現できます。

カスタムイベントは、コンポーネント間を疎結合に保ちたい場合に有効で、モーダル制御や通知システムなど、イベント駆動のアーキテクチャに向いています。 ただし、型安全性が弱く、デバッグが難しくなる可能性があるため、使用は必要最小限に留めるべきでしょう。

Storeは、アプリケーション全体で共有する複雑な状態管理に威力を発揮します。 細粒度のリアクティビティにより高いパフォーマンスを実現し、ユーザーデータやショッピングカートなど、頻繁に更新されるグローバルな状態に最適です。

これら 3 つの手法を適切に使い分けることで、保守性が高く、パフォーマンスに優れた SolidJS アプリケーションを構築できます。 早見表を参考にしながら、それぞれの特性を理解し、プロジェクトの要件に最適な手法を選択していきましょう。

関連リンク