T-CREATOR

SolidJS のカスタムフック(create*系)活用事例集

SolidJS のカスタムフック(create*系)活用事例集

モダンな Web アプリケーション開発において、状態管理やロジックの再利用は重要な課題です。SolidJS では、create*系カスタムフックを活用することで、これらの問題を効率的に解決できます。本記事では、SolidJS のカスタムフックを基本から実践レベルまで段階的に解説し、実際のプロジェクトで活用できる具体例を豊富に紹介します。

カスタムフックを効果的に使いこなすことで、よりメンテナブルで拡張性の高いアプリケーションを構築できるようになります。

背景

SolidJS におけるカスタムフックの役割

SolidJS は、リアクティブプリミティブを基盤とした効率的な状態管理システムを提供しています。カスタムフックは、このリアクティブシステムを活用して、コンポーネント間で共通のロジックを再利用可能な形で抽象化する仕組みです。

mermaidflowchart TD
  component[コンポーネント] -->|呼び出し| hook[カスタムフック]
  hook -->|状態管理| signal[createSignal]
  hook -->|副作用処理| effect[createEffect]
  hook -->|計算結果キャッシュ| memo[createMemo]
  signal -->|リアクティブ更新| ui[UI更新]
  effect -->|リアクティブ更新| ui
  memo -->|リアクティブ更新| ui

この図で示すように、カスタムフックはリアクティブプリミティブの組み合わせを通じて、コンポーネントに状態管理機能を提供します。

カスタムフックの主要な役割は以下の通りです。

状態の抽象化と再利用: 複数のコンポーネントで共通して使用される状態管理ロジックを一箇所にまとめ、再利用可能にします。

関心の分離: ビジネスロジックと UI 表示ロジックを分離し、コードの保守性を向上させます。

テスタビリティの向上: ロジックをフック単位で切り出すことで、単体テストが書きやすくなります。

create*系フックの基本概念

SolidJS で提供される create*系フックは、それぞれ特定の役割を持つリアクティブプリミティブです。これらを理解することが、効果的なカスタムフック作成の基礎となります。

以下の図は、各 create*系フックの関係性と特性を示しています。

mermaidgraph LR
  subgraph "基本プリミティブ"
    signal[createSignal<br/>状態管理]
    effect[createEffect<br/>副作用処理]
  end

  subgraph "最適化プリミティブ"
    memo[createMemo<br/>計算結果キャッシュ]
    resource[createResource<br/>非同期データ取得]
  end

  subgraph "高度な状態管理"
    store[createStore<br/>複雑な状態]
    context[createContext<br/>状態共有]
  end

  signal -->|依存| memo
  signal -->|依存| effect
  resource -->|内部的に使用| signal
  store -->|内部的に使用| signal

各フックの特性を詳しく見てみましょう。

createSignal: 最も基本的な状態管理フック。値の読み取りと更新を提供し、値が変更されるとそれに依存するコンポーネントが自動的に再描画されます。

createEffect: 副作用を扱うフック。シグナルの変更に応じて、DOM 操作や API 呼び出しなどの副作用処理を実行します。

createMemo: 計算結果をキャッシュするフック。依存するシグナルが変更された場合のみ再計算を行い、パフォーマンスを最適化します。

createResource: 非同期データ取得を扱う専用フック。ローディング状態やエラー状態を自動的に管理します。

これらのプリミティブを組み合わせることで、様々な用途に対応したカスタムフックを作成できます。

課題

状態管理の複雑化

現代の Web アプリケーションでは、管理すべき状態が多岐にわたります。ユーザー情報、フォームデータ、API 通信状態、UI 表示状態など、これらを適切に管理することは開発者にとって大きな負担となっています。

以下の図は、状態管理が複雑化する様子を示しています。

mermaidstateDiagram-v2
  [*] --> SimpleState: 初期状態
  SimpleState --> MultipleStates: 機能追加
  MultipleStates --> ComplexInteraction: 状態間の相互作用
  ComplexInteraction --> UnmaintainableCode: 保守困難

  state SimpleState {
    [*] --> UserData
  }

  state MultipleStates {
    [*] --> UserData2: ユーザーデータ
    [*] --> FormData: フォームデータ
    [*] --> ApiState: API状態
    [*] --> UiState: UI状態
  }

  state ComplexInteraction {
    UserData2 --> FormData: 依存関係
    FormData --> ApiState: 送信処理
    ApiState --> UiState: 表示更新
    UiState --> UserData2: フィードバック
  }

複雑化の要因: 各状態が相互に依存し合い、一つの変更が複数の状態に影響を与えるようになります。

スケーラビリティの問題: アプリケーションが成長するにつれて、状態管理コードが肥大化し、新機能の追加が困難になります。

デバッグの困難さ: どの状態がどこで変更されているかを追跡することが難しくなり、バグの特定と修正に時間がかかります。

パフォーマンスの劣化: 不適切な状態管理により、不要な再描画や計算が発生し、アプリケーションの応答性が低下します。

ロジック再利用の困難さ

従来のコンポーネント開発では、似たような状態管理ロジックを複数のコンポーネントで重複して実装することが多々あります。これは以下のような問題を引き起こします。

コードの重複: 同じロジックを複数箇所で実装することにより、コードベース全体の品質が低下し、保守コストが増大します。

一貫性の欠如: 似たような機能でも微妙に実装が異なることで、ユーザー体験に一貫性がなくなります。

修正の困難さ: バグ修正や機能改善を行う際に、関連するすべての箇所を特定して修正する必要があります。

実際のプロジェクトでよく見られる重複パターンの例を見てみましょう。

typescript// コンポーネントAでのフォーム管理
const [formData, setFormData] = createSignal({});
const [errors, setErrors] = createSignal({});
const [isSubmitting, setIsSubmitting] = createSignal(false);
typescript// コンポーネントBでも似たような実装
const [formState, setFormState] = createSignal({});
const [validationErrors, setValidationErrors] =
  createSignal({});
const [submitting, setSubmitting] = createSignal(false);

このようなコードの重複は、プロジェクトの規模が大きくなるほど深刻な問題となります。

パフォーマンス最適化の必要性

SolidJS は高いパフォーマンスを誇るフレームワークですが、不適切な状態管理により、そのメリットを十分に活かせないケースがあります。

不要な再計算: 依存関係を適切に管理しないことで、値が変更されていない場合でも計算処理が実行されてしまいます。

過度な再描画: 関連性の低い状態変更が原因で、不要なコンポーネントの再描画が発生します。

メモリリーク: 適切にクリーンアップされないエフェクトやイベントリスナーにより、メモリ使用量が増大し続けます。

以下の図は、パフォーマンス問題の発生パターンを示しています。

mermaidsequenceDiagram
  participant User
  participant Component
  participant State
  participant Render

  User->>Component: ユーザー操作
  Component->>State: 状態更新(不適切)
  State->>State: 不要な依存関係トリガー
  State->>Render: 複数コンポーネントの再描画
  Render->>Render: 重複計算処理
  Render-->>User: 応答遅延

適切な最適化が行われていない場合: ユーザー操作から表示更新まで不要な処理が多数実行されます。

これらの課題を解決するために、SolidJS の create*系カスタムフックが重要な役割を果たします。

解決策

create*系フックによるロジック分離

create*系フックを活用することで、コンポーネントから状態管理やビジネスロジックを分離し、再利用可能で保守しやすいコードを実現できます。

ロジック分離の基本的な考え方を図で示します。

mermaidflowchart TB
  subgraph "従来の構造"
    comp1[コンポーネント]
    comp1 --> logic1[状態管理ロジック]
    comp1 --> ui1[UI表示ロジック]
    comp1 --> business1[ビジネスロジック]
  end

  subgraph "改善後の構造"
    comp2[コンポーネント]
    comp2 --> ui2[UI表示ロジック]
    comp2 --> hook[カスタムフック]
    hook --> logic2[状態管理ロジック]
    hook --> business2[ビジネスロジック]
  end

分離による利点: コンポーネントは UI 表示に専念し、ロジックはフックが担当することで役割が明確になります。

単一責任の原則: 各カスタムフックは特定の機能に特化し、責任範囲を明確にします。これにより、テストやデバッグが容易になります。

依存関係の明確化: フック内で使用するリアクティブプリミティブの依存関係を明確に定義することで、予期しない副作用を防ぎます。

型安全性の確保: TypeScript と組み合わせることで、フックのインターフェースを厳密に定義し、実行時エラーを削減します。

カスタムフックの設計パターン

効果的なカスタムフックを作成するための設計パターンを紹介します。

命名規則パターン

typescript// 状態を返すフック
const useCounter = () => {
  /* ... */
};

// アクションを含むフック
const useFormHandler = () => {
  /* ... */
};

// 特定のライブラリ連携フック
const useLocalStorage = () => {
  /* ... */
};

戻り値パターン

typescript// オブジェクト形式(推奨)
const useCounter = () => {
  const [count, setCount] = createSignal(0);

  return {
    count,
    increment: () => setCount((c) => c + 1),
    decrement: () => setCount((c) => c - 1),
    reset: () => setCount(0),
  };
};
typescript// 配列形式
const useToggle = (initial = false) => {
  const [state, setState] = createSignal(initial);

  return [
    state,
    () => setState(!state()),
    setState,
  ] as const;
};

戻り値の設計指針: オブジェクト形式は可読性が高く、配列形式は簡潔性を重視する場合に適しています。

エラーハンドリングパターン

typescriptconst useAsyncData = <T>(fetcher: () => Promise<T>) => {
  const [data, setData] = createSignal<T>();
  const [error, setError] = createSignal<Error>();
  const [loading, setLoading] = createSignal(false);

  const execute = async () => {
    try {
      setLoading(true);
      setError(undefined);
      const result = await fetcher();
      setData(result);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  };

  return { data, error, loading, execute };
};

エラーハンドリングの重要性: 非同期処理や API 通信では、エラー状態も含めて管理することが重要です。

これらの設計パターンに従うことで、一貫性があり保守しやすいカスタムフックを作成できます。

具体例

基本レベル:createSignal を使った状態管理

まず、最も基本的な createSignal を使用したカスタムフックから始めましょう。

シンプルなカウンターフック

typescriptimport { createSignal } from 'solid-js';

const useCounter = (initialValue = 0) => {
  const [count, setCount] = createSignal(initialValue);

  return {
    count,
    increment: () => setCount((c) => c + 1),
    decrement: () => setCount((c) => c - 1),
    reset: () => setCount(initialValue),
  };
};

このフックは、カウンター機能を抽象化し、どのコンポーネントからでも再利用可能にします。

使用例:

typescriptimport { Component } from 'solid-js';

const CounterComponent: Component = () => {
  const { count, increment, decrement, reset } =
    useCounter(10);

  return (
    <div>
      <p>Count: {count()}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

トグル状態管理フック

typescriptconst useToggle = (initialState = false) => {
  const [state, setState] = createSignal(initialState);

  const toggle = () => setState((prev) => !prev);
  const setTrue = () => setState(true);
  const setFalse = () => setState(false);

  return {
    state,
    toggle,
    setTrue,
    setFalse,
  };
};

トグルフックは、モーダルの表示/非表示、アコーディオンの開閉など、二値状態の管理に活用できます。

使用例:

typescriptconst ModalComponent: Component = () => {
  const { state: isOpen, toggle, setFalse } = useToggle();

  return (
    <>
      <button onClick={toggle}>Toggle Modal</button>
      {isOpen() && (
        <div class='modal'>
          <p>Modal Content</p>
          <button onClick={setFalse}>Close</button>
        </div>
      )}
    </>
  );
};

入力値管理フック

typescriptconst useInput = (initialValue = '') => {
  const [value, setValue] = createSignal(initialValue);

  const handleInput = (e: Event) => {
    const target = e.target as HTMLInputElement;
    setValue(target.value);
  };

  const clear = () => setValue('');

  return {
    value,
    setValue,
    handleInput,
    clear,
  };
};

入力フィールドの値管理を簡素化し、フォーム処理を効率化します。

使用例:

typescriptconst SearchComponent: Component = () => {
  const { value, handleInput, clear } = useInput();

  return (
    <div>
      <input
        value={value()}
        onInput={handleInput}
        placeholder='Search...'
      />
      <button onClick={clear}>Clear</button>
      <p>Searching for: {value()}</p>
    </div>
  );
};

基本レベルでのポイント: createSignal の基本的な使い方を理解し、シンプルで再利用可能なフックを作成することです。

中級レベル:createEffect を活用した副作用処理

createEffect を使用して、状態変更に応じた副作用処理を管理するカスタムフックを作成します。

ローカルストレージ同期フック

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

const useLocalStorage = <T>(
  key: string,
  initialValue: T
) => {
  // ローカルストレージから初期値を取得
  const getStoredValue = (): T => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  };

  const [value, setValue] = createSignal<T>(
    getStoredValue()
  );

  // 値が変更されたらローカルストレージに保存
  createEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value()));
    } catch (error) {
      console.error(
        'Failed to save to localStorage:',
        error
      );
    }
  });

  return [value, setValue] as const;
};

このフックにより、状態とローカルストレージが自動的に同期されます。

使用例:

typescriptconst UserPreferences: Component = () => {
  const [theme, setTheme] = useLocalStorage(
    'theme',
    'light'
  );
  const [language, setLanguage] = useLocalStorage(
    'language',
    'en'
  );

  return (
    <div>
      <select
        value={theme()}
        onChange={(e) => setTheme(e.target.value)}
      >
        <option value='light'>Light</option>
        <option value='dark'>Dark</option>
      </select>

      <select
        value={language()}
        onChange={(e) => setLanguage(e.target.value)}
      >
        <option value='en'>English</option>
        <option value='ja'>Japanese</option>
      </select>
    </div>
  );
};

ウィンドウサイズ監視フック

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

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

const useWindowSize = () => {
  const [size, setSize] = createSignal<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  createEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handleResize);

    // クリーンアップ処理
    onCleanup(() => {
      window.removeEventListener('resize', handleResize);
    });
  });

  return size;
};

ウィンドウサイズの変更を監視し、レスポンシブな UI を実現します。

使用例:

typescriptconst ResponsiveComponent: Component = () => {
  const windowSize = useWindowSize();

  return (
    <div>
      <p>
        Window: {windowSize().width} x {windowSize().height}
      </p>
      {windowSize().width < 768 ? (
        <MobileView />
      ) : (
        <DesktopView />
      )}
    </div>
  );
};

インターバル管理フック

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

const useInterval = (
  callback: () => void,
  delay: number | null
) => {
  const [isActive, setIsActive] = createSignal(true);

  createEffect(() => {
    if (delay === null || !isActive()) return;

    const interval = setInterval(callback, delay);

    onCleanup(() => {
      clearInterval(interval);
    });
  });

  return {
    isActive,
    start: () => setIsActive(true),
    stop: () => setIsActive(false),
  };
};

タイマーやインターバル処理を安全に管理し、メモリリークを防ぎます。

使用例:

typescriptconst TimerComponent: Component = () => {
  const [seconds, setSeconds] = createSignal(0);

  const { isActive, start, stop } = useInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);

  return (
    <div>
      <p>Timer: {seconds()} seconds</p>
      <button onClick={start} disabled={isActive()}>
        Start
      </button>
      <button onClick={stop} disabled={!isActive()}>
        Stop
      </button>
    </div>
  );
};

中級レベルでのポイント: createEffect と onCleanup を適切に使用し、副作用とリソース管理を行うことです。

上級レベル:createMemo による計算結果キャッシュ

createMemo を活用して、計算コストの高い処理を最適化するカスタムフックを作成します。

フィルタリング&ソート機能フック

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

interface FilterSortOptions<T> {
  filterFn?: (item: T) => boolean;
  sortFn?: (a: T, b: T) => number;
}

const useFilterSort = <T>(
  items: () => T[],
  options: () => FilterSortOptions<T> = () => ({})
) => {
  // フィルタリング結果をメモ化
  const filteredItems = createMemo(() => {
    const { filterFn } = options();
    const itemList = items();

    return filterFn ? itemList.filter(filterFn) : itemList;
  });

  // ソート結果をメモ化
  const sortedItems = createMemo(() => {
    const { sortFn } = options();
    const filtered = filteredItems();

    return sortFn ? [...filtered].sort(sortFn) : filtered;
  });

  return sortedItems;
};

大量のデータを効率的にフィルタリング・ソートし、不要な再計算を防ぎます。

使用例:

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

const ProductList: Component = () => {
  const [products] = createSignal<Product[]>([
    {
      id: 1,
      name: 'Laptop',
      price: 1000,
      category: 'Electronics',
    },
    {
      id: 2,
      name: 'Book',
      price: 20,
      category: 'Education',
    },
    // ... more products
  ]);

  const [searchTerm, setSearchTerm] = createSignal('');
  const [sortBy, setSortBy] = createSignal<
    'name' | 'price'
  >('name');

  const filteredSortedProducts = useFilterSort(
    products,
    () => ({
      filterFn: (product) =>
        product.name
          .toLowerCase()
          .includes(searchTerm().toLowerCase()),
      sortFn: (a, b) =>
        sortBy() === 'name'
          ? a.name.localeCompare(b.name)
          : a.price - b.price,
    })
  );

  return (
    <div>
      <input
        value={searchTerm()}
        onInput={(e) => setSearchTerm(e.target.value)}
        placeholder='Search products...'
      />

      <select
        value={sortBy()}
        onChange={(e) =>
          setSortBy(e.target.value as 'name' | 'price')
        }
      >
        <option value='name'>Sort by Name</option>
        <option value='price'>Sort by Price</option>
      </select>

      <ul>
        {filteredSortedProducts().map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
};

複雑な計算処理フック

typescriptimport { createMemo } from 'solid-js';

interface StatisticsData {
  values: number[];
}

const useStatistics = (data: () => StatisticsData) => {
  // 平均値の計算
  const average = createMemo(() => {
    const values = data().values;
    return values.length > 0
      ? values.reduce((sum, val) => sum + val, 0) /
          values.length
      : 0;
  });

  // 中央値の計算
  const median = createMemo(() => {
    const values = [...data().values].sort((a, b) => a - b);
    const mid = Math.floor(values.length / 2);

    return values.length % 2 === 0
      ? (values[mid - 1] + values[mid]) / 2
      : values[mid];
  });

  // 標準偏差の計算
  const standardDeviation = createMemo(() => {
    const values = data().values;
    const avg = average();

    if (values.length <= 1) return 0;

    const squaredDiffs = values.map((val) =>
      Math.pow(val - avg, 2)
    );
    const avgSquaredDiff =
      squaredDiffs.reduce((sum, val) => sum + val, 0) /
      values.length;

    return Math.sqrt(avgSquaredDiff);
  });

  return {
    average,
    median,
    standardDeviation,
  };
};

統計計算などの重い処理をメモ化し、データが変更された場合のみ再計算を行います。

使用例:

typescriptconst StatisticsDisplay: Component = () => {
  const [dataSet, setDataSet] = createSignal({
    values: [10, 20, 30, 40, 50, 25, 35, 45],
  });

  const stats = useStatistics(dataSet);

  const addRandomValue = () => {
    const newValue = Math.floor(Math.random() * 100);
    setDataSet((prev) => ({
      values: [...prev.values, newValue],
    }));
  };

  return (
    <div>
      <button onClick={addRandomValue}>
        Add Random Value
      </button>

      <div>
        <p>Average: {stats.average().toFixed(2)}</p>
        <p>Median: {stats.median().toFixed(2)}</p>
        <p>
          Standard Deviation:{' '}
          {stats.standardDeviation().toFixed(2)}
        </p>
      </div>

      <p>Values: {dataSet().values.join(', ')}</p>
    </div>
  );
};

検索結果キャッシュフック

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

interface SearchResult<T> {
  items: T[];
  totalCount: number;
  searchTime: number;
}

const useSearch = <T>(
  data: () => T[],
  searchFn: (items: T[], query: string) => T[]
) => {
  const [query, setQuery] = createSignal('');

  // 検索結果をメモ化
  const searchResults = createMemo((): SearchResult<T> => {
    const startTime = performance.now();
    const items = data();
    const searchQuery = query().trim();

    if (!searchQuery) {
      return {
        items,
        totalCount: items.length,
        searchTime: 0,
      };
    }

    const filteredItems = searchFn(items, searchQuery);
    const endTime = performance.now();

    return {
      items: filteredItems,
      totalCount: filteredItems.length,
      searchTime: endTime - startTime,
    };
  });

  return {
    query,
    setQuery,
    searchResults,
  };
};

検索機能にパフォーマンス情報も含めた高機能なフックです。

上級レベルでのポイント: createMemo を効果的に使用し、計算コストの最適化を図ることです。

実践レベル:複数フックを組み合わせた複合パターン

実際のアプリケーションでは、複数の create*系フックを組み合わせて、より複雑で実用的な機能を実現します。

データフェッチング統合フック

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

interface UseApiOptions<T> {
  initialData?: T;
  refetchInterval?: number;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

const useApi = <T>(
  fetcher: () => Promise<T>,
  options: UseApiOptions<T> = {}
) => {
  const {
    initialData,
    refetchInterval,
    onSuccess,
    onError,
  } = options;

  const [resource, { refetch }] = createResource(fetcher, {
    initialValue: initialData,
  });

  // 成功・エラーハンドリング
  createEffect(() => {
    const data = resource();
    const error = resource.error;

    if (data && !resource.loading) {
      onSuccess?.(data);
    }

    if (error) {
      onError?.(error);
    }
  });

  // 自動リフェッチ機能
  createEffect(() => {
    if (!refetchInterval) return;

    const interval = setInterval(refetch, refetchInterval);

    onCleanup(() => {
      clearInterval(interval);
    });
  });

  return {
    data: resource,
    error: () => resource.error,
    loading: () => resource.loading,
    refetch,
  };
};

API 通信の包括的な管理を行う実用的なフックです。

使用例:

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

const UserProfile: Component<{ userId: number }> = (
  props
) => {
  const {
    data: user,
    loading,
    error,
    refetch,
  } = useApi(
    () =>
      fetch(`/api/users/${props.userId}`).then((res) =>
        res.json()
      ),
    {
      refetchInterval: 30000, // 30秒ごとに更新
      onSuccess: (userData) => {
        console.log('User data loaded:', userData);
      },
      onError: (err) => {
        console.error('Failed to load user:', err);
      },
    }
  );

  return (
    <div>
      {loading() && <div>Loading...</div>}
      {error() && (
        <div>
          Error: {error()!.message}
          <button onClick={refetch}>Retry</button>
        </div>
      )}
      {user() && (
        <div>
          <h2>{user()!.name}</h2>
          <p>{user()!.email}</p>
          <button onClick={refetch}>Refresh</button>
        </div>
      )}
    </div>
  );
};

フォーム管理統合フック

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

interface ValidationRule<T> {
  validate: (value: T) => boolean;
  message: string;
}

interface FieldConfig<T> {
  initialValue: T;
  rules?: ValidationRule<T>[];
}

interface FormConfig {
  [key: string]: FieldConfig<any>;
}

const useForm = <T extends FormConfig>(config: T) => {
  // フィールド値の管理
  const fields = Object.keys(config).reduce((acc, key) => {
    const [value, setValue] = createSignal(
      config[key].initialValue
    );
    acc[key] = { value, setValue };
    return acc;
  }, {} as Record<string, any>);

  // バリデーション状態の管理
  const [touched, setTouched] = createSignal<
    Record<string, boolean>
  >({});
  const [isSubmitting, setIsSubmitting] =
    createSignal(false);

  // エラー状態の計算
  const errors = createMemo(() => {
    const errorMap: Record<string, string[]> = {};

    Object.keys(config).forEach((key) => {
      const field = fields[key];
      const rules = config[key].rules || [];
      const fieldErrors: string[] = [];

      rules.forEach((rule) => {
        if (!rule.validate(field.value())) {
          fieldErrors.push(rule.message);
        }
      });

      if (fieldErrors.length > 0) {
        errorMap[key] = fieldErrors;
      }
    });

    return errorMap;
  });

  // フォーム全体の有効性
  const isValid = createMemo(() => {
    return Object.keys(errors()).length === 0;
  });

  // フィールド値の取得
  const getValues = () => {
    const values: Record<string, any> = {};
    Object.keys(fields).forEach((key) => {
      values[key] = fields[key].value();
    });
    return values;
  };

  // フィールドタッチ処理
  const touchField = (fieldName: string) => {
    setTouched((prev) => ({ ...prev, [fieldName]: true }));
  };

  // フォーム送信処理
  const handleSubmit = async (
    onSubmit: (values: any) => Promise<void>
  ) => {
    // すべてのフィールドをタッチ状態にする
    const touchedFields: Record<string, boolean> = {};
    Object.keys(config).forEach((key) => {
      touchedFields[key] = true;
    });
    setTouched(touchedFields);

    if (!isValid()) return;

    try {
      setIsSubmitting(true);
      await onSubmit(getValues());
    } catch (error) {
      console.error('Form submission error:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return {
    fields,
    errors,
    touched,
    isValid,
    isSubmitting,
    getValues,
    touchField,
    handleSubmit,
  };
};

包括的なフォーム管理機能を提供する実用的なフックです。

使用例:

typescriptconst ContactForm: Component = () => {
  const form = useForm({
    name: {
      initialValue: '',
      rules: [
        {
          validate: (v) => v.length > 0,
          message: 'Name is required',
        },
        {
          validate: (v) => v.length <= 50,
          message: 'Name must be 50 characters or less',
        },
      ],
    },
    email: {
      initialValue: '',
      rules: [
        {
          validate: (v) => v.length > 0,
          message: 'Email is required',
        },
        {
          validate: (v) =>
            /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
          message: 'Invalid email format',
        },
      ],
    },
    message: {
      initialValue: '',
      rules: [
        {
          validate: (v) => v.length > 0,
          message: 'Message is required',
        },
        {
          validate: (v) => v.length <= 1000,
          message:
            'Message must be 1000 characters or less',
        },
      ],
    },
  });

  const submitForm = async (values: any) => {
    // API送信処理
    await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });

    alert('Message sent successfully!');
  };

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit(submitForm);
      }}
    >
      <div>
        <input
          value={form.fields.name.value()}
          onInput={(e) =>
            form.fields.name.setValue(e.target.value)
          }
          onBlur={() => form.touchField('name')}
          placeholder='Your Name'
        />
        {form.touched().name && form.errors().name && (
          <div class='error'>
            {form.errors().name.map((error) => (
              <p>{error}</p>
            ))}
          </div>
        )}
      </div>

      <div>
        <input
          type='email'
          value={form.fields.email.value()}
          onInput={(e) =>
            form.fields.email.setValue(e.target.value)
          }
          onBlur={() => form.touchField('email')}
          placeholder='Your Email'
        />
        {form.touched().email && form.errors().email && (
          <div class='error'>
            {form.errors().email.map((error) => (
              <p>{error}</p>
            ))}
          </div>
        )}
      </div>

      <div>
        <textarea
          value={form.fields.message.value()}
          onInput={(e) =>
            form.fields.message.setValue(e.target.value)
          }
          onBlur={() => form.touchField('message')}
          placeholder='Your Message'
        />
        {form.touched().message &&
          form.errors().message && (
            <div class='error'>
              {form.errors().message.map((error) => (
                <p>{error}</p>
              ))}
            </div>
          )}
      </div>

      <button
        type='submit'
        disabled={!form.isValid() || form.isSubmitting()}
      >
        {form.isSubmitting()
          ? 'Sending...'
          : 'Send Message'}
      </button>
    </form>
  );
};

実践レベルでは、複数の create*系フックを組み合わせることで、本格的なアプリケーションに対応できる高機能なカスタムフックを作成できます。

まとめ

SolidJS の create*系カスタムフックは、モダンな Web アプリケーション開発における状態管理の課題を解決する強力なツールです。本記事で紹介した段階的なアプローチにより、効果的なフック活用が可能になります。

重要なポイント

段階的学習の効果: 基本レベルから実践レベルまで段階的に習得することで、各フックの特性を深く理解し、適切な場面で活用できるようになります。

設計パターンの重要性: 命名規則、戻り値パターン、エラーハンドリングパターンに従うことで、一貫性のあるコードベースを維持できます。

パフォーマンス最適化の実現: createMemo による計算結果キャッシュと、適切な依存関係管理により、高パフォーマンスなアプリケーションを構築できます。

実装時の注意点

依存関係の明確化: フック内で使用するリアクティブプリミティブの依存関係を明確にし、予期しない副作用を防ぐことが重要です。

リソース管理: createEffect と onCleanup を適切に使用し、メモリリークやリソースの無駄遣いを防ぎましょう。

型安全性の確保: TypeScript を活用してフックのインターフェースを厳密に定義し、実行時エラーを削減します。

今後の活用指針

以下の表は、各レベルでの活用場面と適用技術をまとめたものです。

#レベル主要技術活用場面習得目標
1基本createSignalシンプルな状態管理基本的なリアクティブシステムの理解
2中級createEffect副作用処理・外部連携ライフサイクル管理とクリーンアップ
3上級createMemoパフォーマンス最適化計算コストの最適化手法
4実践複合パターン実際のアプリケーション開発実用的な機能の統合実装

このように SolidJS の create*系フックを活用することで、保守性が高く、パフォーマンスに優れた Web アプリケーションを効率的に開発できます。

継続的な学習を通じて、より高度なパターンや最新のベストプラクティスも取り入れていくことが、開発スキルの向上につながります。

関連リンク