T-CREATOR

SolidJS のコンポーザブル API でロジック再利用

SolidJS のコンポーザブル API でロジック再利用

モダンなフロントエンド開発において、コードの再利用性は開発効率とメンテナンス性を大きく左右する重要な要素です。SolidJS のコンポーザブル API は、この課題に対する革新的な解決策を提供してくれます。

SolidJS におけるコンポーザブル API の基本概念

SolidJS のコンポーザブル API は、ロジックを抽象化し、複数のコンポーネント間で再利用可能にする仕組みです。これは、リアクティブシステムを活用した独自のアプローチで、従来のコンポーネント設計の限界を超えた柔軟性を提供します。

typescript// 基本的なコンポーザブル関数の例
import { createSignal } from 'solid-js';

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

  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(initialValue);

  return {
    count,
    increment,
    decrement,
    reset,
  };
}

このコンポーザブル関数は、カウンターの状態とそれに関連する操作を一つのパッケージとして提供しています。重要なのは、このロジックが完全に独立していることです。

従来のコンポーネント設計の課題

従来のコンポーネント設計では、ロジックがコンポーネントに密結合されており、再利用が困難でした。以下のような課題が頻繁に発生していました。

重複コードの問題

同じようなロジックを複数のコンポーネントで実装する際、開発者は以下のような問題に直面していました:

typescript// 従来の重複コード例
function UserProfile() {
  const [loading, setLoading] = createSignal(false);
  const [error, setError] = createSignal(null);
  const [user, setUser] = createSignal(null);

  const fetchUser = async (id) => {
    try {
      setLoading(true);
      setError(null);
      const response = await fetch(`/api/users/${id}`);
      if (!response.ok) throw new Error('User not found');
      setUser(await response.json());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {loading() && <div>Loading...</div>}
      {error() && <div>Error: {error()}</div>}
      {user() && <div>User: {user().name}</div>}
    </div>
  );
}

この例では、API 呼び出しとローディング状態の管理が他のコンポーネントでも必要になった場合、同じコードを繰り返し書く必要がありました。

テストの困難性

ロジックがコンポーネントに埋め込まれていると、単体テストが困難になります。以下のようなエラーがよく発生していました:

phpReferenceError: fetch is not defined
    at UserProfile.fetchUser (UserProfile.tsx:12:31)
    at Test.Suite (UserProfile.test.tsx:25:8)

このエラーは、コンポーネントテスト時にブラウザ環境のfetchAPI が利用できないことを示しています。

コンポーザブル API による解決策

SolidJS のコンポーザブル API は、これらの課題を根本的に解決します。ロジックを独立した関数として分離することで、再利用性とテスタビリティが大幅に向上します。

API 通信のコンポーザブル化

先ほどの例を、コンポーザブル API を使って改善してみましょう:

typescript// useApi.ts - 再利用可能なAPIコンポーザブル
import { createSignal, createResource } from 'solid-js';

export function useApi<T>(
  url: string,
  options?: RequestInit
) {
  const [data, setData] = createSignal<T | null>(null);
  const [loading, setLoading] = createSignal(false);
  const [error, setError] = createSignal<string | null>(
    null
  );

  const execute = async (customUrl?: string) => {
    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        customUrl || url,
        options
      );

      if (!response.ok) {
        throw new Error(
          `HTTP Error: ${response.status} ${response.statusText}`
        );
      }

      const result = await response.json();
      setData(result);
      return result;
    } catch (err) {
      const errorMessage =
        err instanceof Error
          ? err.message
          : 'Unknown error';
      setError(errorMessage);
      throw err;
    } finally {
      setLoading(false);
    }
  };

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

この設計により、エラーハンドリングも統一されます。実際によく発生するエラーとその対処法を示します:

typescript// useApiError.ts - エラーハンドリングの専用コンポーザブル
export function useApiError() {
  const [error, setError] = createSignal<ApiError | null>(
    null
  );

  const handleError = (err: unknown) => {
    if (
      err instanceof TypeError &&
      err.message.includes('NetworkError')
    ) {
      setError({
        type: 'network',
        message: 'ネットワークエラーが発生しました',
      });
    } else if (
      err instanceof Error &&
      err.message.includes('404')
    ) {
      setError({
        type: 'notFound',
        message: 'データが見つかりません',
      });
    } else if (
      err instanceof Error &&
      err.message.includes('403')
    ) {
      setError({
        type: 'forbidden',
        message: 'アクセス権限がありません',
      });
    } else {
      setError({
        type: 'unknown',
        message: '予期しないエラーが発生しました',
      });
    }
  };

  return {
    error,
    handleError,
    clearError: () => setError(null),
  };
}

基本的なコンポーザブル関数の作成

SolidJS でコンポーザブル関数を作成する際の基本的なパターンをご紹介します。

ローカルストレージとの連携

実際のアプリケーションでよく使われるローカルストレージとの連携を例に見てみましょう:

typescript// useLocalStorage.ts
import { createSignal, createEffect } from 'solid-js';

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
) {
  // 初期値の取得
  const getStoredValue = (): T => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : defaultValue;
    } catch (error) {
      console.error(
        `Error parsing localStorage key "${key}":`,
        error
      );
      return defaultValue;
    }
  };

  const [storedValue, setStoredValue] = createSignal<T>(
    getStoredValue()
  );

  // 値の更新
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function
          ? value(storedValue())
          : value;
      setStoredValue(valueToStore);
      localStorage.setItem(
        key,
        JSON.stringify(valueToStore)
      );
    } catch (error) {
      console.error(
        `Error setting localStorage key "${key}":`,
        error
      );
    }
  };

  return [storedValue, setValue] as const;
}

この実装では、以下のようなエラーが発生する可能性があります:

vbnetQuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'userSettings' exceeded the storage quota.

このエラーに対処するため、改善版を作成します:

typescript// useLocalStorageWithQuota.ts
export function useLocalStorageWithQuota<T>(
  key: string,
  defaultValue: T
) {
  const [storedValue, setStoredValue] =
    createSignal<T>(defaultValue);
  const [error, setError] = createSignal<string | null>(
    null
  );

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function
          ? value(storedValue())
          : value;
      const serialized = JSON.stringify(valueToStore);

      // サイズチェック(5MBを超える場合は警告)
      if (serialized.length > 5 * 1024 * 1024) {
        setError('データサイズが大きすぎます');
        return;
      }

      localStorage.setItem(key, serialized);
      setStoredValue(valueToStore);
      setError(null);
    } catch (err) {
      if (
        err instanceof DOMException &&
        err.name === 'QuotaExceededError'
      ) {
        setError('ストレージの容量が不足しています');
      } else {
        setError('データの保存に失敗しました');
      }
    }
  };

  return [storedValue, setValue, error] as const;
}

状態管理を含むコンポーザブルの実装

複雑な状態管理を伴うコンポーザブルの実装例をご紹介します。

フォームバリデーションのコンポーザブル

実際の Web アプリケーションで頻繁に必要となるフォームバリデーションを例に見てみましょう:

typescript// useFormValidation.ts
import { createSignal, createMemo } from 'solid-js';

type ValidationRule<T> = {
  validator: (value: T) => boolean;
  message: string;
};

export function useFormValidation<
  T extends Record<string, any>
>(
  initialValues: T,
  rules: Partial<
    Record<keyof T, ValidationRule<T[keyof T]>[]>
  >
) {
  const [values, setValues] =
    createSignal<T>(initialValues);
  const [errors, setErrors] = createSignal<
    Partial<Record<keyof T, string[]>>
  >({});
  const [touched, setTouched] = createSignal<
    Partial<Record<keyof T, boolean>>
  >({});

  const validateField = (
    field: keyof T,
    value: T[keyof T]
  ) => {
    const fieldRules = rules[field] || [];
    const fieldErrors: string[] = [];

    for (const rule of fieldRules) {
      if (!rule.validator(value)) {
        fieldErrors.push(rule.message);
      }
    }

    setErrors((prev) => ({
      ...prev,
      [field]:
        fieldErrors.length > 0 ? fieldErrors : undefined,
    }));

    return fieldErrors.length === 0;
  };

  const setValue = (field: keyof T, value: T[keyof T]) => {
    setValues((prev) => ({ ...prev, [field]: value }));
    setTouched((prev) => ({ ...prev, [field]: true }));
    validateField(field, value);
  };

  const isValid = createMemo(() => {
    const currentErrors = errors();
    return Object.keys(currentErrors).length === 0;
  });

  return {
    values,
    errors,
    touched,
    setValue,
    validateField,
    isValid,
  };
}

このコンポーザブルの使用例:

typescript// LoginForm.tsx
import { useFormValidation } from './useFormValidation';

const LoginForm = () => {
  const { values, errors, touched, setValue, isValid } =
    useFormValidation(
      { email: '', password: '' },
      {
        email: [
          {
            validator: (v) => v.length > 0,
            message: 'メールアドレスは必須です',
          },
          {
            validator: (v) =>
              /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
            message:
              '有効なメールアドレスを入力してください',
          },
        ],
        password: [
          {
            validator: (v) => v.length >= 8,
            message:
              'パスワードは8文字以上で入力してください',
          },
        ],
      }
    );

  const handleSubmit = (e: Event) => {
    e.preventDefault();

    if (!isValid()) {
      console.error('フォームにエラーがあります');
      return;
    }

    console.log('フォーム送信:', values());
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='email'
        value={values().email}
        onInput={(e) => setValue('email', e.target.value)}
        placeholder='メールアドレス'
      />
      {touched().email && errors().email && (
        <div class='error'>{errors().email?.[0]}</div>
      )}

      <input
        type='password'
        value={values().password}
        onInput={(e) =>
          setValue('password', e.target.value)
        }
        placeholder='パスワード'
      />
      {touched().password && errors().password && (
        <div class='error'>{errors().password?.[0]}</div>
      )}

      <button type='submit' disabled={!isValid()}>
        ログイン
      </button>
    </form>
  );
};

カスタムフックとの違いと使い分け

React のカスタムフックと SolidJS のコンポーザブルには重要な違いがあります。

リアクティブシステムの違い

SolidJS のコンポーザブルは、React の hooks とは異なるリアクティブシステムを使用します:

typescript// React カスタムフック(参考)
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount((c) => c + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount((c) => c - 1);
  }, []);

  return { count, increment, decrement };
}

// SolidJS コンポーザブル
function useCounter(initialValue = 0) {
  const [count, setCount] = createSignal(initialValue);

  // useCallbackは不要!SolidJSでは関数は自動的に安定している
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);

  return { count, increment, decrement };
}

パフォーマンスの違い

SolidJS のコンポーザブルでは、依存関係の追跡が自動的に行われます:

typescript// useApiWithAutoRefresh.ts
import { createEffect, onCleanup } from 'solid-js';

export function useApiWithAutoRefresh<T>(
  url: string,
  interval: number = 30000
) {
  const [data, setData] = createSignal<T | null>(null);
  const [loading, setLoading] = createSignal(false);

  const fetchData = async () => {
    try {
      setLoading(true);
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }

      const result = await response.json();
      setData(result);
    } catch (error) {
      console.error('API fetch error:', error);
      // エラーログをサーバーに送信
      if (
        error instanceof TypeError &&
        error.message.includes('NetworkError')
      ) {
        // ネットワークエラーの場合は再試行
        setTimeout(fetchData, 5000);
      }
    } finally {
      setLoading(false);
    }
  };

  // 自動リフレッシュの設定
  createEffect(() => {
    fetchData();
    const intervalId = setInterval(fetchData, interval);

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

  return {
    data,
    loading,
    refetch: fetchData,
  };
}

実際のプロジェクトでの活用事例

実際のプロジェクトでコンポーザブル API を活用した具体的な事例をご紹介します。

認証システムのコンポーザブル

ユーザー認証は多くのアプリケーションで必要となる機能です:

typescript// useAuth.ts
import { createSignal, createEffect } from 'solid-js';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

export function useAuth() {
  const [authState, setAuthState] = createSignal<AuthState>(
    {
      user: null,
      isLoading: true,
      error: null,
    }
  );

  const login = async (email: string, password: string) => {
    try {
      setAuthState((prev) => ({
        ...prev,
        isLoading: true,
        error: null,
      }));

      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) {
        if (response.status === 401) {
          throw new Error(
            'メールアドレスまたはパスワードが正しくありません'
          );
        } else if (response.status === 429) {
          throw new Error(
            'ログイン試行回数が上限に達しました。しばらく待ってから再試行してください'
          );
        }
        throw new Error(
          `ログインに失敗しました: ${response.status}`
        );
      }

      const { user, token } = await response.json();

      // JWTトークンをローカルストレージに保存
      localStorage.setItem('auth_token', token);

      setAuthState({
        user,
        isLoading: false,
        error: null,
      });

      return user;
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : 'ログインに失敗しました';
      setAuthState((prev) => ({
        ...prev,
        isLoading: false,
        error: errorMessage,
      }));
      throw error;
    }
  };

  const logout = () => {
    localStorage.removeItem('auth_token');
    setAuthState({
      user: null,
      isLoading: false,
      error: null,
    });
  };

  // 初期化時にトークンの検証
  createEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (token) {
      validateToken(token);
    } else {
      setAuthState((prev) => ({
        ...prev,
        isLoading: false,
      }));
    }
  });

  const validateToken = async (token: string) => {
    try {
      const response = await fetch('/api/auth/verify', {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      if (response.ok) {
        const user = await response.json();
        setAuthState({
          user,
          isLoading: false,
          error: null,
        });
      } else {
        logout();
      }
    } catch (error) {
      logout();
    }
  };

  return {
    authState,
    login,
    logout,
    isAuthenticated: () => authState().user !== null,
    hasRole: (role: User['role']) =>
      authState().user?.role === role,
  };
}

データキャッシュのコンポーザブル

API からのデータを効率的にキャッシュする機能:

typescript// useCache.ts
import { createSignal, createMemo } from 'solid-js';

interface CacheItem<T> {
  data: T;
  timestamp: number;
  expiresAt: number;
}

export function useCache<T>(ttl: number = 5 * 60 * 1000) {
  // デフォルト5分
  const [cache, setCache] = createSignal<
    Map<string, CacheItem<T>>
  >(new Map());

  const get = (key: string): T | null => {
    const item = cache().get(key);

    if (!item) return null;

    if (Date.now() > item.expiresAt) {
      // 期限切れのアイテムを削除
      setCache((prev) => {
        const newCache = new Map(prev);
        newCache.delete(key);
        return newCache;
      });
      return null;
    }

    return item.data;
  };

  const set = (
    key: string,
    data: T,
    customTtl?: number
  ) => {
    const now = Date.now();
    const item: CacheItem<T> = {
      data,
      timestamp: now,
      expiresAt: now + (customTtl || ttl),
    };

    setCache((prev) => {
      const newCache = new Map(prev);
      newCache.set(key, item);
      return newCache;
    });
  };

  const invalidate = (key: string) => {
    setCache((prev) => {
      const newCache = new Map(prev);
      newCache.delete(key);
      return newCache;
    });
  };

  const clear = () => {
    setCache(new Map());
  };

  // キャッシュサイズの監視
  const cacheSize = createMemo(() => cache().size);

  // 自動クリーンアップ
  const cleanup = () => {
    const now = Date.now();
    setCache((prev) => {
      const newCache = new Map();
      for (const [key, item] of prev) {
        if (now <= item.expiresAt) {
          newCache.set(key, item);
        }
      }
      return newCache;
    });
  };

  return {
    get,
    set,
    invalidate,
    clear,
    cleanup,
    cacheSize,
  };
}

パフォーマンスとメモリ効率の考慮事項

SolidJS のコンポーザブル API を使用する際のパフォーマンスとメモリ効率について、重要なポイントをご紹介します。

メモリリークの回避

コンポーザブルでよく発生するメモリリークとその対策:

typescript// useWebSocket.ts - メモリリーク対策版
import {
  createSignal,
  createEffect,
  onCleanup,
} from 'solid-js';

export function useWebSocket(
  url: string,
  options?: {
    reconnectInterval?: number;
    maxReconnectAttempts?: number;
  }
) {
  const [socket, setSocket] =
    createSignal<WebSocket | null>(null);
  const [connectionState, setConnectionState] =
    createSignal<
      'connecting' | 'connected' | 'disconnected'
    >('disconnected');
  const [error, setError] = createSignal<string | null>(
    null
  );
  const [reconnectAttempts, setReconnectAttempts] =
    createSignal(0);

  const maxAttempts = options?.maxReconnectAttempts || 5;
  const reconnectInterval =
    options?.reconnectInterval || 3000;

  const connect = () => {
    try {
      setConnectionState('connecting');
      setError(null);

      const ws = new WebSocket(url);

      ws.onopen = () => {
        setConnectionState('connected');
        setReconnectAttempts(0);
        console.log('WebSocket connected');
      };

      ws.onclose = (event) => {
        setConnectionState('disconnected');
        setSocket(null);

        if (
          event.code !== 1000 &&
          reconnectAttempts() < maxAttempts
        ) {
          // 異常終了の場合は再接続を試行
          setTimeout(() => {
            setReconnectAttempts((prev) => prev + 1);
            connect();
          }, reconnectInterval);
        }
      };

      ws.onerror = (error) => {
        setError('WebSocket connection error');
        console.error('WebSocket error:', error);
      };

      setSocket(ws);
    } catch (err) {
      setError('Failed to create WebSocket connection');
      setConnectionState('disconnected');
    }
  };

  const disconnect = () => {
    const ws = socket();
    if (ws) {
      ws.close(1000, 'Manual disconnect');
    }
  };

  const send = (data: string | ArrayBuffer) => {
    const ws = socket();
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(data);
    } else {
      throw new Error('WebSocket is not connected');
    }
  };

  // 自動接続
  createEffect(() => {
    connect();
  });

  // クリーンアップ処理
  onCleanup(() => {
    disconnect();
  });

  return {
    connectionState,
    error,
    reconnectAttempts,
    send,
    connect,
    disconnect,
  };
}

パフォーマンス最適化

大量のデータを扱う際のパフォーマンス最適化:

typescript// useVirtualList.ts - 仮想リストの実装
import {
  createSignal,
  createMemo,
  createEffect,
} from 'solid-js';

interface VirtualListOptions {
  itemHeight: number;
  containerHeight: number;
  overscan?: number;
}

export function useVirtualList<T>(
  items: () => T[],
  options: VirtualListOptions
) {
  const [scrollTop, setScrollTop] = createSignal(0);
  const overscan = options.overscan || 5;

  const visibleRange = createMemo(() => {
    const start = Math.floor(
      scrollTop() / options.itemHeight
    );
    const end = Math.min(
      start +
        Math.ceil(
          options.containerHeight / options.itemHeight
        ),
      items().length
    );

    return {
      start: Math.max(0, start - overscan),
      end: Math.min(items().length, end + overscan),
    };
  });

  const visibleItems = createMemo(() => {
    const range = visibleRange();
    const result: Array<{
      item: T;
      index: number;
      top: number;
    }> = [];

    for (let i = range.start; i < range.end; i++) {
      result.push({
        item: items()[i],
        index: i,
        top: i * options.itemHeight,
      });
    }

    return result;
  });

  const totalHeight = createMemo(
    () => items().length * options.itemHeight
  );

  // スクロール位置の範囲チェック
  const clampedScrollTop = createMemo(() => {
    const max = Math.max(
      0,
      totalHeight() - options.containerHeight
    );
    return Math.min(Math.max(0, scrollTop()), max);
  });

  // スクロール位置が変更された時の処理
  createEffect(() => {
    const clamped = clampedScrollTop();
    if (clamped !== scrollTop()) {
      setScrollTop(clamped);
    }
  });

  const scrollToIndex = (index: number) => {
    const targetScrollTop = index * options.itemHeight;
    setScrollTop(targetScrollTop);
  };

  return {
    visibleItems,
    totalHeight,
    scrollTop: clampedScrollTop,
    setScrollTop,
    scrollToIndex,
    visibleRange,
  };
}

この実装により、以下のようなパフォーマンス問題を回避できます:

vbnetWarning: Cannot update a component while rendering a different component. This is a development-only warning.

このエラーは、大量のリストアイテムを一度にレンダリングしようとした際に発生することがあります。

まとめ

SolidJS のコンポーザブル API は、モダンなフロントエンド開発において以下のような価値を提供します:

#特徴説明
1再利用性ロジックを独立した関数として分離し、複数のコンポーネントで再利用可能
2テスタビリティビジネスロジックを単体でテストでき、品質向上に貢献
3パフォーマンスSolidJS のリアクティブシステムを活用した効率的な更新
4保守性関心の分離により、コードの理解と修正が容易
5型安全性TypeScript との親和性により、開発時のエラーを削減

特に重要なのは、**「コードは書く時間よりも読む時間の方が長い」**という事実です。コンポーザブル API を活用することで、6 ヶ月後の自分や、チームメンバーが理解しやすいコードを書くことができます。

また、エラーハンドリングを統一化することで、アプリケーション全体の品質向上にもつながります。これは単なる技術的な改善ではなく、ユーザーエクスペリエンスの向上にも直結する重要な要素です。

コンポーザブル API は、**「小さな改善の積み重ねが大きな価値を生む」**という考え方を体現しています。一つひとつの関数は小さくても、それらが組み合わさることで、保守性が高く、拡張しやすいアプリケーションを構築できるのです。

関連リンク