T-CREATOR

リアクティビティの魔法:SolidJS の自動依存追跡の仕組み

リアクティビティの魔法:SolidJS の自動依存追跡の仕組み

SolidJS を学び始めたとき、「なぜこんなに高速なのか?」「どうして自動で画面が更新されるのか?」と疑問に思った経験はありませんか。その答えは、SolidJS が持つ「リアクティビティシステム」にあります。

今回は、SolidJS の魔法とも言える自動依存追跡の仕組みについて、初心者の方にもわかりやすく解説していきます。コード例とともに、その驚くべき内部の動作を一緒に探っていきましょう。

リアクティビティとは何か

従来のフレームワークとの違い

リアクティビティとは、データの変更を自動的に検知し、関連する処理を実行する仕組みのことです。従来のフレームワークと比較すると、その違いがより明確になります。

フレームワーク更新検知方法更新範囲パフォーマンス
ReactVirtual DOM の差分比較コンポーネント単位中程度
Vue.jsProxy による監視プロパティ単位高い
SolidJS細粒度リアクティビティSignal 単位最高

React では、状態が変更されるたびに Virtual DOM ツリー全体を再計算し、差分を求めて実際の DOM を更新します。この処理は確実ですが、大規模なアプリケーションでは重くなりがちです。

typescript// React の例:コンポーネント全体が再レンダリングされる
function ReactCounter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('太郎');

  // count が変更されると、コンポーネント全体が再実行される
  console.log('コンポーネントが再実行されました');

  return (
    <div>
      <p>
        {name}さんのカウンター: {count}
      </p>
      <button onClick={() => setCount(count + 1)}>
        カウントアップ
      </button>
    </div>
  );
}

SolidJS が目指すリアクティビティ

SolidJS は、必要最小限の部分だけを更新するアプローチを採用しています。これにより、無駄な計算を排除し、驚異的なパフォーマンスを実現しているのです。

typescript// SolidJS の例:変更された部分のみが更新される
function SolidCounter() {
  const [count, setCount] = createSignal(0);
  const [name, setName] = createSignal('太郎');

  // この関数は一度だけ実行される
  console.log('コンポーネントが初期化されました');

  return (
    <div>
      {/* name() が変更されたときのみ、この部分が更新される */}
      <p>
        {name()}さんのカウンター: {count()}
      </p>
      <button onClick={() => setCount(count() + 1)}>
        カウントアップ
      </button>
    </div>
  );
}

この違いを理解することで、SolidJS の真の魅力を感じていただけるでしょう。

Signal の基本的な仕組み

値の監視と通知システム

SolidJS のリアクティビティの核となるのがSignalです。Signal は、値を保持し、その値が変更されたときに自動的に関連する処理を実行する仕組みです。

typescriptimport { createSignal } from 'solid-js';

// Signal の基本的な使い方
const [count, setCount] = createSignal(0);

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

// 値の更新
setCount(10);
console.log(count()); // 10

Signal には、以下の重要な特徴があります:

特徴説明メリット
関数としてアクセスcount() で値を取得依存関係の自動追跡が可能
セッター関数setCount(value) で値を更新更新タイミングの制御
購読者管理内部で購読者リストを管理効率的な通知システム

コード例:Signal の動作確認

実際に Signal がどのように動作するかを確認してみましょう。

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

function SignalDemo() {
  // 複数のSignalを作成
  const [firstName, setFirstName] = createSignal('太郎');
  const [lastName, setLastName] = createSignal('田中');
  const [age, setAge] = createSignal(25);

  // Signalの値を組み合わせる
  const fullName = () => `${lastName()} ${firstName()}`;

  // 値の変更を監視
  createEffect(() => {
    console.log(`名前が更新されました: ${fullName()}`);
  });

  createEffect(() => {
    console.log(`年齢が更新されました: ${age()}歳`);
  });

  return (
    <div>
      <h2>Signal デモ</h2>
      <p>氏名: {fullName()}</p>
      <p>年齢: {age()}歳</p>

      <button onClick={() => setFirstName('花子')}>
        名前を変更
      </button>
      <button onClick={() => setAge(age() + 1)}>
        年齢を増やす
      </button>
    </div>
  );
}

このコードを実行すると、以下のような動作が確認できます:

  1. 「名前を変更」ボタンをクリック → 名前の Effect のみが実行される
  2. 「年齢を増やす」ボタンをクリック → 年齢の Effect のみが実行される

これが、SolidJS の細粒度リアクティビティの威力です。

Effect による自動実行

依存関係の自動検出

createEffectは、SolidJS のリアクティビティシステムの中核を担う機能です。Effect 内で Signal が読み取られると、自動的に依存関係を構築し、その Signal が変更されたときに再実行されます。

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

function EffectDemo() {
  const [x, setX] = createSignal(10);
  const [y, setY] = createSignal(20);
  const [showSum, setShowSum] = createSignal(true);

  // 条件分岐がある場合の依存関係
  createEffect(() => {
    if (showSum()) {
      // showSum が true の場合のみ、x と y に依存
      console.log(`合計: ${x() + y()}`);
    } else {
      // showSum が false の場合は、showSum のみに依存
      console.log('合計は非表示です');
    }
  });

  return (
    <div>
      <h2>Effect デモ</h2>
      <p>
        X: {x()}, Y: {y()}
      </p>
      <p>合計表示: {showSum() ? 'ON' : 'OFF'}</p>

      <button onClick={() => setX(x() + 1)}>
        X を増やす
      </button>
      <button onClick={() => setY(y() + 1)}>
        Y を増やす
      </button>
      <button onClick={() => setShowSum(!showSum())}>
        表示切り替え
      </button>
    </div>
  );
}

この例では、showSumの値によって依存関係が動的に変わることがわかります。これを動的依存関係と呼び、SolidJS の大きな特徴の一つです。

実行タイミングの制御

Effect の実行タイミングは、いくつかのパターンがあります。

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

function TimingDemo() {
  const [count, setCount] = createSignal(0);

  // 1. 即座に実行されるEffect
  createEffect(() => {
    console.log(`即座に実行: ${count()}`);
  });

  // 2. マウント時のみ実行
  onMount(() => {
    console.log('コンポーネントがマウントされました');
  });

  // 3. クリーンアップ処理
  createEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    // クリーンアップ関数を登録
    onCleanup(() => {
      clearInterval(timer);
      console.log('タイマーをクリアしました');
    });
  });

  return <div>カウント: {count()}</div>;
}

よくあるエラーとその解決策

SolidJS のリアクティビティを学ぶ際によく遭遇するエラーをご紹介します。

エラー 1: ReferenceError: createSignal is not defined

typescript// ❌ 間違い:インポートを忘れている
function BadComponent() {
  const [count, setCount] = createSignal(0); // ReferenceError
  return <div>{count()}</div>;
}

// ✅ 正解:適切にインポートする
import { createSignal } from 'solid-js';

function GoodComponent() {
  const [count, setCount] = createSignal(0);
  return <div>{count()}</div>;
}

エラー 2: TypeError: count is not a function

typescript// ❌ 間違い:Signal を関数として呼び出していない
function BadComponent() {
  const [count, setCount] = createSignal(0);
  return <div>{count}</div>; // TypeError: JSX element type 'count' does not exist
}

// ✅ 正解:Signal は必ず関数として呼び出す
function GoodComponent() {
  const [count, setCount] = createSignal(0);
  return <div>{count()}</div>;
}

エラー 3: 無限ループエラー

typescript// ❌ 間違い:Effect内でSignalを更新している
function BadComponent() {
  const [count, setCount] = createSignal(0);

  createEffect(() => {
    console.log(count());
    setCount(count() + 1); // 無限ループが発生
  });

  return <div>{count()}</div>;
}

// ✅ 正解:適切な条件を設ける
function GoodComponent() {
  const [count, setCount] = createSignal(0);

  createEffect(() => {
    console.log(count());
    if (count() < 10) {
      setTimeout(() => setCount(count() + 1), 1000);
    }
  });

  return <div>{count()}</div>;
}

Memo による計算の最適化

キャッシュ機能の仕組み

createMemoは、計算結果をキャッシュして、依存する Signal が変更されたときのみ再計算を行う機能です。重い計算処理がある場合に威力を発揮します。

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

function MemoDemo() {
  const [numbers, setNumbers] = createSignal([
    1, 2, 3, 4, 5,
  ]);
  const [multiplier, setMultiplier] = createSignal(1);

  // 重い計算処理をMemoで最適化
  const expensiveCalculation = createMemo(() => {
    console.log('重い計算を実行中...');

    // 意図的に重い処理をシミュレート
    let result = 0;
    const nums = numbers();
    for (let i = 0; i < nums.length; i++) {
      for (let j = 0; j < 1000000; j++) {
        result += nums[i] * multiplier();
      }
    }

    return result;
  });

  // 計算結果が変更されたときのみ実行される
  createEffect(() => {
    console.log(`計算結果: ${expensiveCalculation()}`);
  });

  return (
    <div>
      <h2>Memo デモ</h2>
      <p>計算結果: {expensiveCalculation()}</p>
      <p>倍数: {multiplier()}</p>

      <button
        onClick={() => setMultiplier(multiplier() + 1)}
      >
        倍数を増やす
      </button>
      <button
        onClick={() =>
          setNumbers([...numbers(), numbers().length + 1])
        }
      >
        数値を追加
      </button>
    </div>
  );
}

無駄な計算を避ける方法

Memo と通常の関数の違いを理解することが重要です。

方法再計算タイミングパフォーマンス使用場面
通常の関数呼び出されるたび低い軽い計算
createMemo依存関係変更時のみ高い重い計算
typescriptimport { createSignal, createMemo } from 'solid-js';

function OptimizationDemo() {
  const [input, setInput] = createSignal('');
  const [count, setCount] = createSignal(0);

  // ❌ 悪い例:毎回再計算される
  const badFiltered = () => {
    console.log('毎回実行される重い処理');
    return input()
      .toLowerCase()
      .split('')
      .reverse()
      .join('');
  };

  // ✅ 良い例:inputが変更されたときのみ再計算
  const goodFiltered = createMemo(() => {
    console.log('inputが変更されたときのみ実行');
    return input()
      .toLowerCase()
      .split('')
      .reverse()
      .join('');
  });

  return (
    <div>
      <h2>最適化デモ</h2>
      <input
        value={input()}
        onInput={(e) => setInput(e.target.value)}
        placeholder='文字を入力してください'
      />
      <p>カウント: {count()}</p>
      <p>悪い例の結果: {badFiltered()}</p>
      <p>良い例の結果: {goodFiltered()}</p>

      <button onClick={() => setCount(count() + 1)}>
        カウント増加(計算への影響を確認)
      </button>
    </div>
  );
}

このコードを実行すると、カウントボタンをクリックしたときに:

  • 悪い例では毎回重い処理が実行される
  • 良い例では処理が実行されない

という違いを確認できます。

依存グラフの可視化

どのように依存関係が構築されるか

SolidJS の内部では、Signal と依存関係をグラフ構造で管理しています。これを理解することで、より効率的なコードが書けるようになります。

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

function DependencyDemo() {
  // レベル1: 基本データ
  const [firstName, setFirstName] = createSignal('太郎');
  const [lastName, setLastName] = createSignal('田中');
  const [age, setAge] = createSignal(25);

  // レベル2: 計算されたデータ
  const fullName = createMemo(
    () => `${lastName()} ${firstName()}`
  );
  const isAdult = createMemo(() => age() >= 18);

  // レベル3: さらに高次の計算
  const greeting = createMemo(() => {
    const status = isAdult() ? '成人' : '未成年';
    return `こんにちは、${fullName()}さん(${status})`;
  });

  // 依存関係の可視化
  createEffect(() => {
    console.log('=== 依存関係の追跡 ===');
    console.log(`firstName: ${firstName()}`);
    console.log(`lastName: ${lastName()}`);
    console.log(`age: ${age()}`);
    console.log(`fullName: ${fullName()}`);
    console.log(`isAdult: ${isAdult()}`);
    console.log(`greeting: ${greeting()}`);
  });

  return (
    <div>
      <h2>依存関係デモ</h2>
      <p>{greeting()}</p>

      <div>
        <button onClick={() => setFirstName('花子')}>
          名前変更
        </button>
        <button onClick={() => setLastName('佐藤')}>
          苗字変更
        </button>
        <button onClick={() => setAge(15)}>
          年齢を15歳に
        </button>
      </div>
    </div>
  );
}

この例では、以下のような依存グラフが構築されます:

markdownfirstName ─┐
           ├─ fullName ─┐
lastName ──┘            ├─ greeting
                        │
age ────── isAdult ─────┘

デバッグツールでの確認方法

SolidJS には、依存関係を確認するためのデバッグツールも用意されています。

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

function DebugDemo() {
  const [count, setCount] = createSignal(0);
  const [name, setName] = createSignal('太郎');

  // デバッグ情報を出力するEffect
  createEffect(() => {
    const owner = getOwner();
    console.log('現在のOwner:', owner);
    console.log(`count: ${count()}, name: ${name()}`);
  });

  // 開発時のみデバッグ情報を表示
  if (import.meta.env.DEV) {
    createEffect(() => {
      console.group('デバッグ情報');
      console.log('count の値:', count());
      console.log('name の値:', name());
      console.groupEnd();
    });
  }

  return (
    <div>
      <h2>デバッグデモ</h2>
      <p>Count: {count()}</p>
      <p>Name: {name()}</p>

      <button onClick={() => setCount(count() + 1)}>
        カウント増加
      </button>
      <button
        onClick={() =>
          setName(name() === '太郎' ? '花子' : '太郎')
        }
      >
        名前切り替え
      </button>
    </div>
  );
}

開発者ツールの活用

Chrome の開発者ツールで SolidJS アプリをデバッグする際のポイントをご紹介します。

typescript// Vite設定でSolidJSのデバッグを有効化
// vite.config.ts
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';

export default defineConfig({
  plugins: [
    solid({
      // 開発時のみデバッグ情報を含める
      dev: true,
      // ソースマップを有効化
      babel: {
        plugins: [
          // SolidJSのデバッグプラグインを追加
          [
            'babel-plugin-jsx-dom-expressions',
            {
              // デバッグ情報を含める
              generate: 'dom',
              hydratable: false,
            },
          ],
        ],
      },
    }),
  ],
  // 開発サーバーの設定
  server: {
    port: 3000,
    open: true,
  },
  // ビルド設定
  build: {
    // ソースマップを生成
    sourcemap: true,
    // デバッグ用の情報を保持
    minify: false,
  },
});

パフォーマンス最適化のベストプラクティス

Signal 設計のコツ

効率的なリアクティビティシステムを構築するためのポイントをまとめました。

原則説明
単一責任1 つの Signal は 1 つの値のみ管理ユーザー情報は分割する
適切な粒度更新頻度に応じて粒度を調整頻繁に変わる値は細かく分割
計算の分離重い計算は Memo で分離検索結果のフィルタリング
typescript// ❌ 悪い例:巨大なオブジェクトを一つのSignalで管理
const [user, setUser] = createSignal({
  name: '太郎',
  age: 25,
  email: 'taro@example.com',
  preferences: {
    theme: 'dark',
    language: 'ja',
    notifications: true,
  },
});

// ✅ 良い例:適切に分割して管理
const [userName, setUserName] = createSignal('太郎');
const [userAge, setUserAge] = createSignal(25);
const [userEmail, setUserEmail] = createSignal(
  'taro@example.com'
);
const [theme, setTheme] = createSignal('dark');
const [language, setLanguage] = createSignal('ja');
const [notifications, setNotifications] =
  createSignal(true);

// 必要に応じて組み合わせる
const userProfile = createMemo(() => ({
  name: userName(),
  age: userAge(),
  email: userEmail(),
}));

const userPreferences = createMemo(() => ({
  theme: theme(),
  language: language(),
  notifications: notifications(),
}));

メモリリークの回避

長時間実行されるアプリケーションでは、メモリリークに注意が必要です。

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

function MemoryLeakDemo() {
  const [isActive, setIsActive] = createSignal(false);

  createEffect(() => {
    if (isActive()) {
      // リソースを使用する処理
      const timer = setInterval(() => {
        console.log('定期実行中...');
      }, 1000);

      const eventListener = () => {
        console.log('ウィンドウサイズが変更されました');
      };

      window.addEventListener('resize', eventListener);

      // ✅ 重要:クリーンアップ処理を必ず記述
      onCleanup(() => {
        clearInterval(timer);
        window.removeEventListener('resize', eventListener);
        console.log('リソースをクリーンアップしました');
      });
    }
  });

  return (
    <div>
      <h2>メモリリーク対策デモ</h2>
      <p>アクティブ状態: {isActive() ? 'ON' : 'OFF'}</p>
      <button onClick={() => setIsActive(!isActive())}>
        切り替え
      </button>
    </div>
  );
}

まとめ

SolidJS のリアクティビティシステムは、従来のフレームワークとは大きく異なるアプローチを採用しています。その核心となる要素を改めて整理してみましょう。

重要なポイント

  1. Signal: 値を保持し、変更を自動通知する仕組み
  2. Effect: Signal の変更を監視し、自動実行される処理
  3. Memo: 計算結果をキャッシュし、パフォーマンスを最適化
  4. 細粒度リアクティビティ: 必要最小限の更新で高速動作を実現

学習で得られるメリット

  • 高パフォーマンス: Virtual DOM を使わない効率的な更新
  • 直感的な記述: 関数型プログラミングの自然な表現
  • メンテナンス性: 依存関係が明確で理解しやすいコード
  • デバッグ容易性: 問題の特定と修正が簡単

これらの概念を理解することで、より効率的で保守性の高い Web アプリケーションを開発できるようになります。SolidJS のリアクティビティシステムは、一度慣れてしまえば、他のフレームワークでは物足りなく感じるほどの快適さを提供してくれるでしょう。

関連リンク