T-CREATOR

Signal・Store・Memo 完全解説:SolidJS の状態管理パターン

Signal・Store・Memo 完全解説:SolidJS の状態管理パターン

SolidJS では、状態管理において 3 つの主要な仕組みが提供されています。SignalStore、そして Memo です。これらはそれぞれ異なる特性を持ち、適切に使い分けることで高パフォーマンスなアプリケーションを構築できます。

本記事では、それぞれの仕組みを詳しく解説し、実践的な使い分けパターンまでご紹介いたします。React や Vue.js からの移行を検討している方にとっても、SolidJS の状態管理の魅力を理解していただけるでしょう。

Signal の基本概念と仕組み

Signal とは何か

Signal は SolidJS における最も基本的な状態管理の仕組みです。リアクティブなデータの格納と更新を担当し、値が変更された際に自動的に依存するコンポーネントを再レンダリングします。

React の useState に似ていますが、SolidJS では Virtual DOM を使わない細粒度更新を実現しているため、より効率的な更新が可能です。

typescriptimport { createSignal } from 'solid-js';

// 基本的なSignalの作成
const [count, setCount] = createSignal(0);

// 現在の値を取得
console.log(count()); // 0

// 値を更新
setCount(5);
console.log(count()); // 5

createSignal の基本的な使い方

createSignal は配列を返し、最初の要素がゲッター関数、2 番目の要素がセッター関数となります。この設計により、値の取得と設定を明確に分離できます。

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

const Counter: Component = () => {
  // 初期値0でSignalを作成
  const [count, setCount] = createSignal(0);

  // インクリメント関数
  const increment = () => {
    setCount(count() + 1);
  };

  // デクリメント関数
  const decrement = () => {
    setCount(count() - 1);
  };

  return (
    <div>
      <p>カウント: {count()}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
};

Signal のリアクティブシステム

SolidJS の Signal は、依存関係を自動的に追跡するリアクティブシステムを持っています。Signal の値が変更されると、その値を参照しているすべてのコンポーネントや計算が自動的に更新されます。

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

const App = () => {
  const [name, setName] = createSignal('太郎');
  const [age, setAge] = createSignal(25);

  // Signalの変更を監視するEffect
  createEffect(() => {
    console.log(`${name()}さんは${age()}歳です`);
  });

  // nameが変更されると、自動的にeffectが実行される
  setTimeout(() => setName('花子'), 2000);
  // ageが変更されても、同様にeffectが実行される
  setTimeout(() => setAge(30), 4000);

  return (
    <div>
      <h1>{name()}さんのプロフィール</h1>
      <p>年齢: {age()}歳</p>
      <button onClick={() => setAge(age() + 1)}>
        誕生日
      </button>
    </div>
  );
};

よくある使用例と注意点

Signal は様々な場面で活用できますが、適切な使い方を理解することが重要です。

フォーム状態の管理

typescriptconst LoginForm = () => {
  const [email, setEmail] = createSignal('');
  const [password, setPassword] = createSignal('');
  const [isLoading, setIsLoading] = createSignal(false);
  const [error, setError] = createSignal<string | null>(
    null
  );

  const handleSubmit = async (e: Event) => {
    e.preventDefault();
    setIsLoading(true);
    setError(null);

    try {
      // API呼び出しの例
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email: email(),
          password: password(),
        }),
      });

      if (!response.ok) {
        throw new Error('ログインに失敗しました');
      }

      // 成功時の処理
      console.log('ログイン成功');
    } catch (err) {
      setError(
        err instanceof Error
          ? err.message
          : '不明なエラーが発生しました'
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>メールアドレス:</label>
        <input
          type='email'
          value={email()}
          onInput={(e) => setEmail(e.target.value)}
          disabled={isLoading()}
        />
      </div>

      <div>
        <label>パスワード:</label>
        <input
          type='password'
          value={password()}
          onInput={(e) => setPassword(e.target.value)}
          disabled={isLoading()}
        />
      </div>

      {error() && (
        <div style={{ color: 'red' }}>
          エラー: {error()}
        </div>
      )}

      <button type='submit' disabled={isLoading()}>
        {isLoading() ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
};

よくあるエラーと対処法

エラー 1: Signal is not a function

vbnetTypeError: count is not a function

このエラーは、Signal の値を取得する際に関数呼び出し () を忘れた場合に発生します。

typescript// ❌ 間違い
const [count, setCount] = createSignal(0);
console.log(count); // Signalオブジェクトが出力される

// ✅ 正しい
console.log(count()); // 0が出力される

エラー 2: Cannot call a class as a function

vbnetTypeError: Cannot call a class as a function

セッター関数を誤って呼び出し忘れた場合に発生します。

typescript// ❌ 間違い
setCount = 5; // 代入してしまっている

// ✅ 正しい
setCount(5); // 関数として呼び出す

エラー 3: Maximum call stack size exceeded

arduinoRangeError: Maximum call stack size exceeded

Signal の更新時に無限ループが発生した場合のエラーです。

typescript// ❌ 無限ループを引き起こす例
createEffect(() => {
  setCount(count() + 1); // Effect内でSignalを更新すると無限ループ
});

// ✅ 正しい例
const increment = () => {
  setCount(count() + 1);
};

Store による複雑な状態管理

Store の概念と利点

Signal が単一の値を管理するのに対し、Store は複雑なオブジェクトや配列などの構造化されたデータの管理に特化しています。Store を使用することで、ネストしたオブジェクトの部分更新を効率的に行えます。

Store の主な利点は以下の通りです:

特徴SignalStore
データ構造単一の値オブジェクト・配列
更新粒度全体更新部分更新
パフォーマンスシンプルな値に最適複雑な構造に最適
メモリ効率高い更新された部分のみ

createStore の基本操作

createStore は、オブジェクトをリアクティブにし、その変更を効率的に追跡します。

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

// 基本的なStoreの作成
const [user, setUser] = createStore({
  name: '田中太郎',
  age: 30,
  email: 'tanaka@example.com',
  preferences: {
    theme: 'light',
    language: 'ja',
  },
});

// 値の読み取り
console.log(user.name); // '田中太郎'
console.log(user.preferences.theme); // 'light'

// 値の更新
setUser('name', '佐藤花子');
setUser('preferences', 'theme', 'dark');

// 複数のプロパティを同時に更新
setUser({
  age: 31,
  email: 'sato@example.com',
});

ネストした状態の管理

Store は深くネストしたオブジェクトの管理において真価を発揮します。従来の状態管理では困難だった、深い階層の部分更新を簡単に実現できます。

typescriptinterface TodoItem {
  id: number;
  text: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
  tags: string[];
  createdAt: Date;
  updatedAt: Date;
}

interface AppState {
  todos: TodoItem[];
  filters: {
    status: 'all' | 'active' | 'completed';
    priority: 'all' | 'low' | 'medium' | 'high';
    tags: string[];
  };
  ui: {
    isLoading: boolean;
    error: string | null;
    selectedTodoId: number | null;
  };
}

const TodoApp = () => {
  const [state, setState] = createStore<AppState>({
    todos: [],
    filters: {
      status: 'all',
      priority: 'all',
      tags: [],
    },
    ui: {
      isLoading: false,
      error: null,
      selectedTodoId: null,
    },
  });

  // Todo追加関数
  const addTodo = (text: string) => {
    const newTodo: TodoItem = {
      id: Date.now(),
      text,
      completed: false,
      priority: 'medium',
      tags: [],
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    setState('todos', (todos) => [...todos, newTodo]);
  };

  // Todo完了切り替え関数
  const toggleTodo = (id: number) => {
    setState(
      'todos',
      (todo) => todo.id === id,
      'completed',
      (completed) => !completed
    );

    // 更新日時も同時に更新
    setState(
      'todos',
      (todo) => todo.id === id,
      'updatedAt',
      new Date()
    );
  };

  // フィルター更新関数
  const updateFilter = (
    filterType: keyof AppState['filters'],
    value: any
  ) => {
    setState('filters', filterType, value);
  };

  // エラー設定関数
  const setError = (error: string | null) => {
    setState('ui', 'error', error);
  };

  // ローディング状態切り替え
  const setLoading = (isLoading: boolean) => {
    setState('ui', 'isLoading', isLoading);
  };

  return (
    <div>
      {state.ui.isLoading && <div>読み込み中...</div>}

      {state.ui.error && (
        <div style={{ color: 'red' }}>
          エラー: {state.ui.error}
        </div>
      )}

      <div>
        <button onClick={() => addTodo('新しいタスク')}>
          タスク追加
        </button>
      </div>

      <div>
        {state.todos.map((todo) => (
          <div key={todo.id}>
            <input
              type='checkbox'
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span
              style={{
                textDecoration: todo.completed
                  ? 'line-through'
                  : 'none',
              }}
            >
              {todo.text}
            </span>
            <span>優先度: {todo.priority}</span>
          </div>
        ))}
      </div>
    </div>
  );
};

Store のパフォーマンス特性

Store は、変更された部分のみを追跡する細粒度更新により、高いパフォーマンスを実現します。

typescript// パフォーマンス測定の例
const PerformanceTest = () => {
  const [largeState, setLargeState] = createStore({
    items: Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      value: Math.random(),
      selected: false,
    })),
    metadata: {
      totalCount: 10000,
      selectedCount: 0,
    },
  });

  // 特定のアイテムのみを更新
  const updateSingleItem = (id: number) => {
    const start = performance.now();

    setLargeState(
      'items',
      (item) => item.id === id,
      'selected',
      (selected) => !selected
    );

    const end = performance.now();
    console.log(`更新時間: ${end - start}ms`);
  };

  // よくあるパフォーマンスの問題と解決策

  // ❌ 非効率な全体更新
  const inefficientUpdate = () => {
    setLargeState(
      'items',
      largeState.items.map((item) =>
        item.id === 100
          ? { ...item, selected: !item.selected }
          : item
      )
    );
  };

  // ✅ 効率的な部分更新
  const efficientUpdate = () => {
    setLargeState(
      'items',
      (item) => item.id === 100,
      'selected',
      (selected) => !selected
    );
  };

  return (
    <div>
      <button onClick={() => updateSingleItem(100)}>
        アイテム100を更新
      </button>
      <div>
        選択されたアイテム数:{' '}
        {
          largeState.items.filter((item) => item.selected)
            .length
        }
      </div>
    </div>
  );
};

Store でよくあるエラーと対処法

エラー 1: Cannot add property, object is not extensible

csharpTypeError: Cannot add property 'newField', object is not extensible

Store オブジェクトに存在しないプロパティを追加しようとした場合のエラーです。

typescript// ❌ 存在しないプロパティへの追加
const [state, setState] = createStore({ name: '太郎' });
setState('age', 30); // エラー

// ✅ 初期状態で定義するか、reconcile を使用
const [state, setState] = createStore({
  name: '太郎',
  age: undefined as number | undefined,
});
setState('age', 30); // OK

// または reconcile を使用
import { reconcile } from 'solid-js/store';
setState(reconcile({ ...state, age: 30 }));

エラー 2: Cannot read property of undefined

javascriptTypeError: Cannot read property 'theme' of undefined

ネストしたオブジェクトが未定義の場合に発生するエラーです。

typescript// ❌ ネストしたオブジェクトが未定義
const [state, setState] = createStore<{
  user?: { preferences?: { theme: string } };
}>({});

console.log(state.user.preferences.theme); // エラー

// ✅ 適切なデフォルト値の設定
const [state, setState] = createStore({
  user: {
    preferences: {
      theme: 'light',
    },
  },
});

// または条件付きアクセス
console.log(state.user?.preferences?.theme);

Memo による計算値の最適化

createMemo の役割と重要性

createMemo は、計算コストが高い処理をキャッシュし、依存する値が変更された時のみ再計算を行う仕組みです。React の useMemo に似ていますが、SolidJS では自動的な依存関係追跡により、より効率的な動作を実現します。

Memo が特に有効な場面:

シーン従来の問題Memo による解決
重い計算処理毎回再計算される依存値変更時のみ実行
フィルタリング全データを毎回処理元データ変更時のみ
API レスポンス加工レンダリング毎に処理データ更新時のみ
複雑な条件判定毎回全条件をチェック関連値変更時のみ

依存関係の自動追跡

SolidJS の Memo は、実行時に参照される Signal や Store を自動的に追跡し、それらの値が変更された際にのみ再計算を行います。

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

const ShoppingCart = () => {
  const [items, setItems] = createSignal([
    { id: 1, name: 'リンゴ', price: 150, quantity: 3 },
    { id: 2, name: 'バナナ', price: 200, quantity: 2 },
    { id: 3, name: 'オレンジ', price: 180, quantity: 1 },
  ]);

  const [taxRate, setTaxRate] = createSignal(0.1);
  const [discountRate, setDiscountRate] =
    createSignal(0.05);

  // 小計の計算(重い処理をシミュレート)
  const subtotal = createMemo(() => {
    console.log('小計を計算中...'); // この処理は必要な時のみ実行される

    return items().reduce((total, item) => {
      return total + item.price * item.quantity;
    }, 0);
  });

  // 税込み価格の計算
  const totalWithTax = createMemo(() => {
    console.log('税込み価格を計算中...');
    return subtotal() * (1 + taxRate());
  });

  // 最終価格の計算(割引適用後)
  const finalPrice = createMemo(() => {
    console.log('最終価格を計算中...');
    return totalWithTax() * (1 - discountRate());
  });

  // アイテム数の計算
  const totalItems = createMemo(() => {
    return items().reduce(
      (total, item) => total + item.quantity,
      0
    );
  });

  // 商品追加関数
  const addItem = (name: string, price: number) => {
    setItems((prev) => [
      ...prev,
      {
        id: Date.now(),
        name,
        price,
        quantity: 1,
      },
    ]);
  };

  // 数量更新関数
  const updateQuantity = (id: number, quantity: number) => {
    setItems((prev) =>
      prev.map((item) =>
        item.id === id ? { ...item, quantity } : item
      )
    );
  };

  return (
    <div>
      <h2>ショッピングカート</h2>

      <div>
        {items().map((item) => (
          <div key={item.id}>
            <span>
              {item.name} - ¥{item.price}
            </span>
            <input
              type='number'
              value={item.quantity}
              min='0'
              onChange={(e) =>
                updateQuantity(
                  item.id,
                  parseInt(e.target.value) || 0
                )
              }
            />
          </div>
        ))}
      </div>

      <button onClick={() => addItem('ぶどう', 300)}>
        商品追加
      </button>

      <div>
        <p>商品数: {totalItems()}個</p>
        <p>小計: ¥{subtotal().toLocaleString()}</p>
        <p>税込み: ¥{totalWithTax().toLocaleString()}</p>
        <p>最終価格: ¥{finalPrice().toLocaleString()}</p>
      </div>

      <div>
        <label>
          税率:
          <input
            type='number'
            value={taxRate() * 100}
            onChange={(e) =>
              setTaxRate(parseFloat(e.target.value) / 100)
            }
            step='0.1'
          />%
        </label>
      </div>

      <div>
        <label>
          割引率:
          <input
            type='number'
            value={discountRate() * 100}
            onChange={(e) =>
              setDiscountRate(
                parseFloat(e.target.value) / 100
              )
            }
            step='0.1'
          />%
        </label>
      </div>
    </div>
  );
};

パフォーマンス最適化のポイント

Memo を効果的に活用するための重要なポイントをご紹介します。

重い計算処理のメモ化

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

const DataAnalysis = () => {
  const [rawData, setRawData] = createSignal<number[]>([]);
  const [filterThreshold, setFilterThreshold] =
    createSignal(50);

  // 生データから統計情報を計算(重い処理)
  const statistics = createMemo(() => {
    const data = rawData();
    if (data.length === 0) return null;

    console.log('統計計算を実行中...'); // デバッグ用

    // 重い計算処理をシミュレート
    const sorted = [...data].sort((a, b) => a - b);
    const sum = data.reduce((acc, val) => acc + val, 0);
    const mean = sum / data.length;

    // 分散の計算
    const variance =
      data.reduce((acc, val) => {
        return acc + Math.pow(val - mean, 2);
      }, 0) / data.length;

    const standardDeviation = Math.sqrt(variance);

    return {
      count: data.length,
      sum,
      mean,
      median: sorted[Math.floor(sorted.length / 2)],
      min: sorted[0],
      max: sorted[sorted.length - 1],
      variance,
      standardDeviation,
    };
  });

  // フィルタリングされたデータ
  const filteredData = createMemo(() => {
    const threshold = filterThreshold();
    return rawData().filter((value) => value >= threshold);
  });

  // フィルタリング後の統計
  const filteredStatistics = createMemo(() => {
    const data = filteredData();
    if (data.length === 0) return null;

    const sum = data.reduce((acc, val) => acc + val, 0);
    return {
      count: data.length,
      sum,
      mean: sum / data.length,
      min: Math.min(...data),
      max: Math.max(...data),
    };
  });

  // データ生成関数
  const generateRandomData = (count: number) => {
    const data = Array.from({ length: count }, () =>
      Math.floor(Math.random() * 100)
    );
    setRawData(data);
  };

  return (
    <div>
      <h2>データ分析ダッシュボード</h2>

      <div>
        <button onClick={() => generateRandomData(1000)}>
          1000件のデータを生成
        </button>
        <button onClick={() => generateRandomData(10000)}>
          10000件のデータを生成
        </button>
      </div>

      <div>
        <label>
          フィルター閾値:
          <input
            type='range'
            min='0'
            max='100'
            value={filterThreshold()}
            onInput={(e) =>
              setFilterThreshold(parseInt(e.target.value))
            }
          />
          {filterThreshold()}
        </label>
      </div>

      {statistics() && (
        <div>
          <h3>全データ統計</h3>
          <table>
            <tbody>
              <tr>
                <td>件数</td>
                <td>{statistics()!.count}</td>
              </tr>
              <tr>
                <td>合計</td>
                <td>
                  {statistics()!.sum.toLocaleString()}
                </td>
              </tr>
              <tr>
                <td>平均</td>
                <td>{statistics()!.mean.toFixed(2)}</td>
              </tr>
              <tr>
                <td>中央値</td>
                <td>{statistics()!.median}</td>
              </tr>
              <tr>
                <td>最小値</td>
                <td>{statistics()!.min}</td>
              </tr>
              <tr>
                <td>最大値</td>
                <td>{statistics()!.max}</td>
              </tr>
              <tr>
                <td>標準偏差</td>
                <td>
                  {statistics()!.standardDeviation.toFixed(
                    2
                  )}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      )}

      {filteredStatistics() && (
        <div>
          <h3>フィルター後統計</h3>
          <table>
            <tbody>
              <tr>
                <td>件数</td>
                <td>{filteredStatistics()!.count}</td>
              </tr>
              <tr>
                <td>平均</td>
                <td>
                  {filteredStatistics()!.mean.toFixed(2)}
                </td>
              </tr>
              <tr>
                <td>最小値</td>
                <td>{filteredStatistics()!.min}</td>
              </tr>
              <tr>
                <td>最大値</td>
                <td>{filteredStatistics()!.max}</td>
              </tr>
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
};

メモ化のベストプラクティス

1. 適切な粒度でのメモ化

typescript// ❌ 過度に細かいメモ化
const TooGranular = () => {
  const [a, setA] = createSignal(1);
  const [b, setB] = createSignal(2);

  // これらは軽い計算なので、メモ化の必要性は低い
  const aPlusOne = createMemo(() => a() + 1);
  const bMinusOne = createMemo(() => b() - 1);
  const sum = createMemo(() => aPlusOne() + bMinusOne());

  // ...
};

// ✅ 適切な粒度でのメモ化
const Appropriate = () => {
  const [a, setA] = createSignal(1);
  const [b, setB] = createSignal(2);

  // 複数の計算をまとめてメモ化
  const calculations = createMemo(() => ({
    aPlusOne: a() + 1,
    bMinusOne: b() - 1,
    sum: a() + 1 + (b() - 1),
  }));

  // ...
};

2. 非同期処理との組み合わせ

typescriptimport {
  createSignal,
  createMemo,
  createResource,
} from 'solid-js';

const AsyncMemoExample = () => {
  const [userId, setUserId] = createSignal(1);

  // ユーザーデータを取得するResource
  const [userData] = createResource(userId, async (id) => {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  });

  // ユーザーデータが取得できた時のみ計算を実行
  const userProfile = createMemo(() => {
    const user = userData();
    if (!user) return null;

    return {
      fullName: `${user.firstName} ${user.lastName}`,
      age:
        new Date().getFullYear() -
        new Date(user.birthDate).getFullYear(),
      isAdult:
        new Date().getFullYear() -
          new Date(user.birthDate).getFullYear() >=
        20,
      profileComplete: Boolean(
        user.firstName && user.lastName && user.email
      ),
    };
  });

  return (
    <div>
      <select
        value={userId()}
        onChange={(e) =>
          setUserId(parseInt(e.target.value))
        }
      >
        <option value={1}>ユーザー1</option>
        <option value={2}>ユーザー2</option>
        <option value={3}>ユーザー3</option>
      </select>

      {userData.loading && <div>読み込み中...</div>}
      {userData.error && (
        <div>エラー: {userData.error.message}</div>
      )}

      {userProfile() && (
        <div>
          <h3>{userProfile()!.fullName}</h3>
          <p>年齢: {userProfile()!.age}歳</p>
          <p>
            成人:{' '}
            {userProfile()!.isAdult ? 'はい' : 'いいえ'}
          </p>
          <p>
            プロフィール:{' '}
            {userProfile()!.profileComplete
              ? '完了'
              : '未完了'}
          </p>
        </div>
      )}
    </div>
  );
};

よくある Memo のエラーと対処法

エラー 1: Maximum call stack size exceeded (createMemo)

arduinoRangeError: Maximum call stack size exceeded

Memo の中で自分自身を参照することで発生する循環参照エラーです。

typescript// ❌ 循環参照を引き起こす例
const BadMemo = () => {
  const [value, setValue] = createSignal(0);

  const memoValue = createMemo(() => {
    return memoValue() + value(); // 自分自身を参照
  });

  // ...
};

// ✅ 正しい実装
const GoodMemo = () => {
  const [value, setValue] = createSignal(0);
  const [multiplier, setMultiplier] = createSignal(2);

  const memoValue = createMemo(() => {
    return value() * multiplier();
  });

  // ...
};

エラー 2: Cannot read property of undefined in memo

javascriptTypeError: Cannot read property 'length' of undefined

Memo 内で undefined の可能性がある値にアクセスしようとした場合のエラーです。

typescript// ❌ undefined チェックを忘れた例
const UnsafeMemo = () => {
  const [data, setData] = createSignal<
    string[] | undefined
  >();

  const processedData = createMemo(() => {
    return data().map((item) => item.toUpperCase()); // data()がundefinedの場合エラー
  });
};

// ✅ 適切なガード句を含む実装
const SafeMemo = () => {
  const [data, setData] = createSignal<
    string[] | undefined
  >();

  const processedData = createMemo(() => {
    const currentData = data();
    if (!currentData) return [];
    return currentData.map((item) => item.toUpperCase());
  });
};

3 つの使い分けパターン

どの状態管理を選ぶべきか

適切な状態管理手法を選択することで、パフォーマンスと開発効率を大幅に向上させることができます。以下の判断基準を参考にしてください。

状況推奨手法理由
単一の値(文字列、数値、真偽値)Signalシンプルで軽量、オーバーヘッドが少ない
複雑なオブジェクト・配列Store部分更新による高いパフォーマンス
重い計算処理が必要Memo不要な再計算を避けられる
フォームの入力値Signal各フィールドごとに独立した管理
ユーザー情報、設定データStoreネストした構造の効率的な更新
API レスポンスの加工Memoデータ変更時のみ再処理

判断フローチャート

typescript// 状態管理選択の実例
const StateManagementExample = () => {
  // 1. 単純な値 → Signal
  const [isLoading, setIsLoading] = createSignal(false);
  const [currentPage, setCurrentPage] = createSignal(1);
  const [searchQuery, setSearchQuery] = createSignal('');

  // 2. 複雑な構造 → Store
  const [appState, setAppState] = createStore({
    user: {
      id: null as number | null,
      name: '',
      email: '',
      preferences: {
        theme: 'light' as 'light' | 'dark',
        notifications: true,
      },
    },
    posts: [] as Array<{
      id: number;
      title: string;
      content: string;
      tags: string[];
      createdAt: Date;
    }>,
    ui: {
      sidebarOpen: false,
      selectedPostId: null as number | null,
    },
  });

  // 3. 計算処理 → Memo
  const filteredPosts = createMemo(() => {
    const query = searchQuery().toLowerCase();
    if (!query) return appState.posts;

    return appState.posts.filter(
      (post) =>
        post.title.toLowerCase().includes(query) ||
        post.content.toLowerCase().includes(query) ||
        post.tags.some((tag) =>
          tag.toLowerCase().includes(query)
        )
    );
  });

  const pageCount = createMemo(() => {
    return Math.ceil(filteredPosts().length / 10);
  });

  const currentPagePosts = createMemo(() => {
    const start = (currentPage() - 1) * 10;
    return filteredPosts().slice(start, start + 10);
  });

  return (
    <div>
      {/* 実装例 */}
      <input
        type='text'
        placeholder='検索...'
        value={searchQuery()}
        onInput={(e) => setSearchQuery(e.target.value)}
      />

      <div>
        {currentPagePosts().map((post) => (
          <article key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.content}</p>
          </article>
        ))}
      </div>

      <div>
        ページ {currentPage()} / {pageCount()}
      </div>
    </div>
  );
};

組み合わせて使う実践例

実際のアプリケーションでは、3 つの手法を組み合わせて使用することが一般的です。

typescript// 実践的な組み合わせ例:ブログ管理システム
const BlogManagementSystem = () => {
  // Signal: 単純な状態管理
  const [isEditMode, setIsEditMode] = createSignal(false);
  const [selectedFilter, setSelectedFilter] =
    createSignal('all');
  const [sortOrder, setSortOrder] = createSignal<
    'asc' | 'desc'
  >('desc');

  // Store: 複雑なデータ構造
  const [blogState, setBlogState] = createStore({
    posts: [] as BlogPost[],
    categories: [] as Category[],
    tags: [] as Tag[],
    currentPost: null as BlogPost | null,
    drafts: [] as BlogPost[],
    publishedPosts: [] as BlogPost[],
  });

  // API から データを取得する関数
  const loadPosts = async () => {
    try {
      const response = await fetch('/api/posts');
      const posts = await response.json();
      setBlogState('posts', posts);
    } catch (error) {
      console.error('投稿の読み込みに失敗:', error);
    }
  };

  // Memo: 計算処理の最適化
  const filteredPosts = createMemo(() => {
    let posts = blogState.posts;

    // フィルタリング
    if (selectedFilter() !== 'all') {
      posts = posts.filter(
        (post) => post.status === selectedFilter()
      );
    }

    // ソート
    posts = [...posts].sort((a, b) => {
      const compareValue =
        new Date(a.createdAt).getTime() -
        new Date(b.createdAt).getTime();
      return sortOrder() === 'asc'
        ? compareValue
        : -compareValue;
    });

    return posts;
  });

  const postsByCategory = createMemo(() => {
    const categorizedPosts = new Map<string, BlogPost[]>();

    filteredPosts().forEach((post) => {
      const category = post.category || 'uncategorized';
      if (!categorizedPosts.has(category)) {
        categorizedPosts.set(category, []);
      }
      categorizedPosts.get(category)!.push(post);
    });

    return categorizedPosts;
  });

  const postStatistics = createMemo(() => {
    const posts = blogState.posts;
    return {
      total: posts.length,
      published: posts.filter(
        (p) => p.status === 'published'
      ).length,
      draft: posts.filter((p) => p.status === 'draft')
        .length,
      scheduled: posts.filter(
        (p) => p.status === 'scheduled'
      ).length,
      totalViews: posts.reduce(
        (sum, p) => sum + (p.views || 0),
        0
      ),
      avgViews:
        posts.length > 0
          ? posts.reduce(
              (sum, p) => sum + (p.views || 0),
              0
            ) / posts.length
          : 0,
    };
  });

  // 投稿作成・更新関数
  const createPost = (
    postData: Omit<BlogPost, 'id' | 'createdAt'>
  ) => {
    const newPost: BlogPost = {
      ...postData,
      id: Date.now(),
      createdAt: new Date(),
    };
    setBlogState('posts', (posts) => [...posts, newPost]);
  };

  const updatePost = (
    postId: number,
    updates: Partial<BlogPost>
  ) => {
    setBlogState('posts', (post) => post.id === postId, {
      ...updates,
      updatedAt: new Date(),
    });
  };

  const deletePost = (postId: number) => {
    setBlogState('posts', (posts) =>
      posts.filter((p) => p.id !== postId)
    );
  };

  return (
    <div class='blog-management'>
      {/* 統計情報 */}
      <div class='stats-panel'>
        <h2>統計情報</h2>
        <div class='stats-grid'>
          <div>総投稿数: {postStatistics().total}</div>
          <div>公開済み: {postStatistics().published}</div>
          <div>下書き: {postStatistics().draft}</div>
          <div>
            総閲覧数:{' '}
            {postStatistics().totalViews.toLocaleString()}
          </div>
          <div>
            平均閲覧数:{' '}
            {postStatistics().avgViews.toFixed(1)}
          </div>
        </div>
      </div>

      {/* フィルター・ソート */}
      <div class='controls'>
        <select
          value={selectedFilter()}
          onChange={(e) =>
            setSelectedFilter(e.target.value)
          }
        >
          <option value='all'>すべて</option>
          <option value='published'>公開済み</option>
          <option value='draft'>下書き</option>
          <option value='scheduled'>予約投稿</option>
        </select>

        <button
          onClick={() =>
            setSortOrder(
              sortOrder() === 'asc' ? 'desc' : 'asc'
            )
          }
        >
          並び順: {sortOrder() === 'asc' ? '昇順' : '降順'}
        </button>

        <button
          onClick={() => setIsEditMode(!isEditMode())}
        >
          {isEditMode() ? '閲覧モード' : '編集モード'}
        </button>
      </div>

      {/* 投稿一覧 */}
      <div class='posts-container'>
        {Array.from(postsByCategory().entries()).map(
          ([category, posts]) => (
            <div key={category} class='category-section'>
              <h3>{category}</h3>
              <div class='posts-grid'>
                {posts.map((post) => (
                  <article key={post.id} class='post-card'>
                    <h4>{post.title}</h4>
                    <p>{post.excerpt}</p>
                    <div class='post-meta'>
                      <span>状態: {post.status}</span>
                      <span>閲覧数: {post.views || 0}</span>
                      <span>
                        作成日:{' '}
                        {post.createdAt.toLocaleDateString()}
                      </span>
                    </div>
                    {isEditMode() && (
                      <div class='post-actions'>
                        <button
                          onClick={() =>
                            updatePost(post.id, {
                              status: 'published',
                            })
                          }
                        >
                          公開
                        </button>
                        <button
                          onClick={() =>
                            deletePost(post.id)
                          }
                        >
                          削除
                        </button>
                      </div>
                    )}
                  </article>
                ))}
              </div>
            </div>
          )
        )}
      </div>
    </div>
  );
};

// 型定義
interface BlogPost {
  id: number;
  title: string;
  content: string;
  excerpt: string;
  status: 'draft' | 'published' | 'scheduled';
  category?: string;
  tags: string[];
  views?: number;
  createdAt: Date;
  updatedAt?: Date;
}

interface Category {
  id: number;
  name: string;
  slug: string;
}

interface Tag {
  id: number;
  name: string;
  count: number;
}

パフォーマンスを考慮した選択指針

パフォーマンスを最大化するための選択指針をご紹介します。

1. メモリ使用量の考慮

typescript// メモリ効率を考慮した実装例
const MemoryEfficientComponent = () => {
  // ❌ 大量のSignalを作成(メモリ無駄遣い)
  const [field1, setField1] = createSignal('');
  const [field2, setField2] = createSignal('');
  const [field3, setField3] = createSignal('');
  // ... 50個のフィールド

  // ✅ Storeで一括管理(メモリ効率良い)
  const [formData, setFormData] = createStore({
    field1: '',
    field2: '',
    field3: '',
    // ... 他のフィールド
  });

  // 必要な場合のみMemoで計算
  const formValidation = createMemo(() => {
    // 全フィールドが入力されている場合のみ重いバリデーションを実行
    const hasAllRequiredFields = Boolean(
      formData.field1 && formData.field2 && formData.field3
    );

    if (!hasAllRequiredFields) {
      return {
        isValid: false,
        errors: ['必須フィールドが未入力です'],
      };
    }

    // 重いバリデーション処理
    return performComplexValidation(formData);
  });

  return (
    <form>
      <input
        value={formData.field1}
        onInput={(e) =>
          setFormData('field1', e.target.value)
        }
      />
      {/* 他のフィールド */}
    </form>
  );
};

2. 更新頻度による選択

typescript// 更新頻度に応じた最適化
const FrequencyOptimizedComponent = () => {
  // 高頻度更新 → Signal(軽量)
  const [mousePosition, setMousePosition] = createSignal({
    x: 0,
    y: 0,
  });
  const [scrollPosition, setScrollPosition] =
    createSignal(0);

  // 中頻度更新 → Store(構造化データ)
  const [uiState, setUiState] = createStore({
    modalOpen: false,
    activeTab: 'home',
    notifications: [] as Notification[],
  });

  // 低頻度・重い計算 → Memo
  const expensiveCalculation = createMemo(() => {
    // mousePositionが変更されても、この計算は実行されない
    return performExpensiveOperation(uiState.notifications);
  });

  // イベントリスナーの設定
  onMount(() => {
    const handleMouseMove = (e: MouseEvent) => {
      setMousePosition({ x: e.clientX, y: e.clientY });
    };

    const handleScroll = () => {
      setScrollPosition(window.scrollY);
    };

    document.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('scroll', handleScroll);

    onCleanup(() => {
      document.removeEventListener(
        'mousemove',
        handleMouseMove
      );
      window.removeEventListener('scroll', handleScroll);
    });
  });

  return (
    <div>
      <div>
        マウス位置: {mousePosition().x}, {mousePosition().y}
      </div>
      <div>スクロール位置: {scrollPosition()}</div>
      <div>重い計算結果: {expensiveCalculation()}</div>
    </div>
  );
};

まとめ

SolidJS の状態管理システムは、SignalStoreMemo の 3 つの柱で構成されており、それぞれが特定の用途に最適化されています。適切な使い分けにより、高パフォーマンスで保守性の高いアプリケーションを構築できます。

主要なポイント

手法適用場面主な利点注意点
Signal単純な値の管理軽量、高速、シンプル複雑な構造には不向き
Store構造化データ部分更新、メモリ効率初期学習コスト
Memo計算処理の最適化不要な再計算を回避過度な使用は逆効果

選択の基本原則

  1. シンプルな値は Signal を使用

    • 文字列、数値、真偽値などの単純なデータ
    • フォームの入力値、UI の状態フラグ
  2. 複雑な構造は Store を使用

    • オブジェクト、配列、ネストしたデータ
    • ユーザー情報、アプリケーション設定
  3. 重い計算は Memo で最適化

    • フィルタリング、ソート、統計計算
    • API レスポンスの加工処理

開発効率を向上させるコツ

  • 型安全性を活用:TypeScript と組み合わせて、コンパイル時エラーを活用
  • デバッグツールの使用:SolidJS DevTools でリアクティブシステムを可視化
  • パフォーマンス測定performance.now() を使って実際の改善効果を測定
  • 段階的な導入:既存のコードベースに少しずつ導入して効果を確認

SolidJS の状態管理は、React や Vue.js とは異なるアプローチを取っていますが、一度慣れてしまえば非常に強力なツールとなります。特に、自動的な依存関係追跡により、開発者が明示的に依存配列を管理する必要がないため、バグの発生を大幅に減らせるでしょう。

実際のプロジェクトでは、これら 3 つの手法を組み合わせて使用することで、パフォーマンスと開発体験の両方を最適化できます。ぜひ、小さなプロジェクトから始めて、SolidJS の状態管理の威力を体感してみてください。

関連リンク