T-CREATOR

SolidJS のコンポーネント設計ベストプラクティス

SolidJS のコンポーネント設計ベストプラクティス

モダンなフロントエンド開発において、SolidJS は注目を集めている革新的なフレームワークです。React ライクな記法でありながら、実際の DOM を直接操作することで高いパフォーマンスを実現しています。しかし、その特徴を活かすためには、適切なコンポーネント設計が不可欠です。

この記事では、SolidJS を使った開発で実際に遭遇する課題や、効果的なコンポーネント設計の手法について、具体的なコード例とともに詳しく解説いたします。初心者の方でも理解しやすいよう、基礎から実践的な応用まで段階的にご紹介していきますね。

SolidJS コンポーネントの基礎知識

SolidJS の特徴と他フレームワークとの違い

SolidJS は、React の影響を受けながらも独自のアプローチでパフォーマンスを追求したフレームワークです。最大の特徴は、仮想 DOM を使用せずに直接 DOM を操作することで、高速な描画を実現している点です。

特徴SolidJSReactVue.js
DOM 操作直接操作仮想 DOM仮想 DOM
再描画細粒度コンポーネント単位コンポーネント単位
バンドルサイズ小さい中程度中程度
学習コスト低い(React 経験者)中程度中程度

シグナルベースの状態管理

SolidJS の核心となるのがシグナル(Signal)です。これは、値が変更されたときに依存する箇所だけを更新する仕組みです。

以下は、シグナルの基本的な使用例です:

typescriptimport { createSignal } from 'solid-js';

function Counter() {
  // シグナルの作成 - [getter, setter]のペアを返す
  const [count, setCount] = createSignal(0);

  return (
    <div>
      <p>現在のカウント: {count()}</p>
      <button onClick={() => setCount(count() + 1)}>
        +1
      </button>
    </div>
  );
}

このコードでは、countシグナルが変更されるたびに、そのシグナルを参照している部分のみが更新されます。

よくある初心者エラー

SolidJS を始めたばかりの方によく見られるエラーをご紹介します:

エラー 1: シグナルを関数として呼び出し忘れ

typescript// ❌ 間違った使い方
function BadExample() {
  const [count, setCount] = createSignal(0);

  return <div>{count}</div>; // Error: count is not a function
}

// ✅ 正しい使い方
function GoodExample() {
  const [count, setCount] = createSignal(0);

  return <div>{count()}</div>; // count()として関数呼び出し
}

エラー 2: エフェクト内での依存関係の誤解

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

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

  // ❌ エフェクト内でシグナルを使用しない
  createEffect(() => {
    console.log('This runs only once');
  });

  // ✅ エフェクト内でシグナルを使用する
  createEffect(() => {
    console.log('Count changed:', count());
  });

  return (
    <button onClick={() => setCount(count() + 1)}>
      Click
    </button>
  );
}

これらのエラーは、SolidJS の設計思想を理解することで避けることができます。シグナルは必ず関数として呼び出し、エフェクト内で使用することで自動的に依存関係が追跡されるのです。

基本的なコンポーネント設計パターン

関数コンポーネントの基本構造

SolidJS では、関数コンポーネントは一度だけ実行されます。これは React とは大きく異なる点で、パフォーマンスの向上に寄与しています。

基本的なコンポーネントの構造は以下のようになります:

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

// 基本的なコンポーネント構造
function MyComponent() {
  // 1. シグナルの定義
  const [isVisible, setIsVisible] = createSignal(true);

  // 2. エフェクトの定義
  createEffect(() => {
    console.log('可視状態が変更されました:', isVisible());
  });

  // 3. クリーンアップ処理
  onCleanup(() => {
    console.log('コンポーネントが破棄されました');
  });

  // 4. JSXの返却
  return (
    <div>
      {isVisible() && <p>表示中です</p>}
      <button onClick={() => setIsVisible(!isVisible())}>
        切り替え
      </button>
    </div>
  );
}

プロパティ(Props)の設計

SolidJS における Props は、コンポーネントの外部から渡される値です。TypeScript と組み合わせることで、型安全なコンポーネントを作成できます。

typescript// Props の型定義
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  onClick?: () => void;
  children: any;
}

// コンポーネントの実装
function Button(props: ButtonProps) {
  return (
    <button
      class={`btn btn-${props.variant || 'primary'} btn-${
        props.size || 'medium'
      }`}
      disabled={props.disabled}
      onClick={props.onClick}
    >
      {props.children}
    </button>
  );
}

状態管理の基本

SolidJS では、状態管理にシグナルを使用します。複雑な状態の場合は、createStoreを使用してオブジェクトや配列を管理できます。

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

function StateExample() {
  // 単純な状態管理
  const [count, setCount] = createSignal(0);

  // 複雑な状態管理
  const [user, setUser] = createStore({
    name: '',
    email: '',
    preferences: {
      theme: 'light',
      notifications: true,
    },
  });

  const updateUserName = (name: string) => {
    setUser('name', name);
  };

  const updateTheme = (theme: 'light' | 'dark') => {
    setUser('preferences', 'theme', theme);
  };

  return (
    <div>
      <p>カウント: {count()}</p>
      <p>ユーザー名: {user.name}</p>
      <p>テーマ: {user.preferences.theme}</p>

      <button onClick={() => setCount(count() + 1)}>
        カウントアップ
      </button>

      <input
        type='text'
        value={user.name}
        onInput={(e) => updateUserName(e.target.value)}
        placeholder='ユーザー名を入力'
      />

      <button
        onClick={() =>
          updateTheme(
            user.preferences.theme === 'light'
              ? 'dark'
              : 'light'
          )
        }
      >
        テーマ切り替え
      </button>
    </div>
  );
}

再利用可能なコンポーネント設計

コンポーネントの分割戦略

再利用可能なコンポーネントを設計するには、適切な分割戦略が重要です。以下の原則に従うことで、保守性の高いコードを作成できます:

分割の原則説明具体例
単一責任1 つのコンポーネントは 1 つの責任を持つButton コンポーネントはボタンの見た目と動作のみ
疎結合他のコンポーネントに依存しない外部 API の呼び出し処理を分離
高凝集関連する機能をまとめるフォーム関連の処理を 1 つのコンポーネントに

実践的なコンポーネント分割の例:

typescript// ❌ 責任が多すぎる例
function TodoApp() {
  const [todos, setTodos] = createStore([]);
  const [filter, setFilter] = createSignal('all');

  // API呼び出し
  const fetchTodos = async () => {
    // 複雑なAPI処理...
  };

  // フィルタリング
  const filteredTodos = () => {
    // 複雑なフィルタリング処理...
  };

  return <div>{/* 複雑なJSX構造 */}</div>;
}
typescript// ✅ 適切に分割された例
function TodoApp() {
  const [todos, setTodos] = createStore([]);
  const [filter, setFilter] = createSignal('all');

  return (
    <div>
      <TodoFilter
        filter={filter}
        onFilterChange={setFilter}
      />
      <TodoList todos={todos} filter={filter()} />
      <TodoForm
        onAddTodo={(todo) => setTodos([...todos, todo])}
      />
    </div>
  );
}

// 個別のコンポーネント
function TodoFilter(props: {
  filter: Accessor<string>;
  onFilterChange: Setter<string>;
}) {
  return (
    <div>
      <button onClick={() => props.onFilterChange('all')}>
        すべて
      </button>
      <button
        onClick={() => props.onFilterChange('completed')}
      >
        完了済み
      </button>
      <button
        onClick={() => props.onFilterChange('active')}
      >
        未完了
      </button>
    </div>
  );
}

Props インターフェースの設計

型安全で使いやすい Props インターフェースを設計することで、コンポーネントの再利用性が向上します。

typescript// 基本的なProps設計
interface CardProps {
  title: string;
  description?: string;
  image?: string;
  actions?: Array<{
    label: string;
    onClick: () => void;
    variant?: 'primary' | 'secondary';
  }>;
  className?: string;
}

function Card(props: CardProps) {
  return (
    <div class={`card ${props.className || ''}`}>
      {props.image && (
        <img src={props.image} alt={props.title} />
      )}

      <div class='card-content'>
        <h3 class='card-title'>{props.title}</h3>
        {props.description && (
          <p class='card-description'>
            {props.description}
          </p>
        )}

        {props.actions && (
          <div class='card-actions'>
            {props.actions.map((action, index) => (
              <button
                key={index}
                class={`btn btn-${
                  action.variant || 'primary'
                }`}
                onClick={action.onClick}
              >
                {action.label}
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

デフォルト値の適切な設定

コンポーネントの使いやすさを向上させるため、適切なデフォルト値を設定することが重要です。

typescript// デフォルト値の設定方法
interface InputProps {
  type?: 'text' | 'email' | 'password' | 'number';
  placeholder?: string;
  value?: string;
  onInput?: (value: string) => void;
  disabled?: boolean;
  required?: boolean;
  size?: 'small' | 'medium' | 'large';
}

function Input(props: InputProps) {
  // デフォルト値の定義
  const defaultProps = {
    type: 'text',
    placeholder: '',
    disabled: false,
    required: false,
    size: 'medium',
  };

  // propsとデフォルト値のマージ
  const mergedProps = { ...defaultProps, ...props };

  return (
    <input
      type={mergedProps.type}
      placeholder={mergedProps.placeholder}
      value={mergedProps.value}
      onInput={(e) => mergedProps.onInput?.(e.target.value)}
      disabled={mergedProps.disabled}
      required={mergedProps.required}
      class={`input input-${mergedProps.size}`}
    />
  );
}

より洗練されたアプローチとして、SolidJS のmergePropsを使用することもできます:

typescriptimport { mergeProps } from 'solid-js';

function Input(props: InputProps) {
  const merged = mergeProps(
    {
      type: 'text' as const,
      placeholder: '',
      disabled: false,
      required: false,
      size: 'medium' as const,
    },
    props
  );

  return (
    <input
      type={merged.type}
      placeholder={merged.placeholder}
      value={merged.value}
      onInput={(e) => merged.onInput?.(e.target.value)}
      disabled={merged.disabled}
      required={merged.required}
      class={`input input-${merged.size}`}
    />
  );
}

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

シグナルの効果的な使用

SolidJS のパフォーマンスを最大化するには、シグナルの適切な使用が重要です。以下のポイントを押さえましょう:

1. 細粒度な状態管理

typescript// ❌ 粗い粒度の状態管理
function BadExample() {
  const [appState, setAppState] = createStore({
    user: { name: 'John', email: 'john@example.com' },
    ui: { theme: 'light', sidebarOpen: true },
    data: { todos: [], loading: false },
  });

  // 一つの値の変更が全体に影響
  const updateUserName = (name: string) => {
    setAppState('user', 'name', name);
  };

  return <div>{appState.user.name}</div>;
}

// ✅ 細粒度な状態管理
function GoodExample() {
  const [userName, setUserName] = createSignal('John');
  const [theme, setTheme] = createSignal('light');
  const [sidebarOpen, setSidebarOpen] = createSignal(true);

  // 必要な部分のみ更新
  return <div>{userName()}</div>;
}

2. 派生状態の活用

typescriptimport { createMemo } from 'solid-js';

function UserProfile() {
  const [user, setUser] = createStore({
    firstName: 'John',
    lastName: 'Doe',
    email: 'john@example.com',
  });

  // 派生状態をメモ化
  const fullName = createMemo(
    () => `${user.firstName} ${user.lastName}`
  );
  const emailDomain = createMemo(
    () => user.email.split('@')[1]
  );

  return (
    <div>
      <h1>{fullName()}</h1>
      <p>Domain: {emailDomain()}</p>
      <input
        value={user.firstName}
        onInput={(e) =>
          setUser('firstName', e.target.value)
        }
      />
    </div>
  );
}

メモ化の活用

createMemoを使用して、計算コストの高い処理をメモ化することで、パフォーマンスを向上させることができます。

typescript// 重い計算処理の例
function ExpensiveCalculation() {
  const [numbers, setNumbers] = createSignal([
    1, 2, 3, 4, 5,
  ]);
  const [multiplier, setMultiplier] = createSignal(1);

  // ❌ 毎回計算される
  const badSum = () => {
    console.log('Heavy calculation running...');
    return numbers().reduce(
      (sum, num) => sum + num * multiplier(),
      0
    );
  };

  // ✅ 依存関係が変わったときのみ計算
  const memoizedSum = createMemo(() => {
    console.log('Memoized calculation running...');
    return numbers().reduce(
      (sum, num) => sum + num * multiplier(),
      0
    );
  });

  return (
    <div>
      <p>Bad sum: {badSum()}</p>
      <p>Memoized sum: {memoizedSum()}</p>
      <button
        onClick={() => setMultiplier(multiplier() + 1)}
      >
        Increase multiplier
      </button>
    </div>
  );
}

不要な再レンダリングの防止

1. 条件付きレンダリングの最適化

typescriptimport { Show, Switch, Match } from 'solid-js';

function ConditionalRendering() {
  const [status, setStatus] = createSignal<
    'loading' | 'success' | 'error'
  >('loading');

  // ❌ 毎回JSXが評価される
  const badCondition = () => {
    if (status() === 'loading')
      return <div>Loading...</div>;
    if (status() === 'success') return <div>Success!</div>;
    return <div>Error!</div>;
  };

  return (
    <div>
      {/* ❌ 非効率 */}
      {badCondition()}

      {/* ✅ 効率的 */}
      <Switch>
        <Match when={status() === 'loading'}>
          <div>Loading...</div>
        </Match>
        <Match when={status() === 'success'}>
          <div>Success!</div>
        </Match>
        <Match when={status() === 'error'}>
          <div>Error!</div>
        </Match>
      </Switch>
    </div>
  );
}

2. リストレンダリングの最適化

typescriptimport { For, Index } from 'solid-js';

function OptimizedList() {
  const [items, setItems] = createSignal([
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' },
    { id: 3, name: 'Item 3' },
  ]);

  return (
    <div>
      {/* ❌ 配列の順序が変わると全て再レンダリング */}
      <For each={items()}>
        {(item) => <div>{item.name}</div>}
      </For>

      {/* ✅ インデックスベースで最適化 */}
      <Index each={items()}>
        {(item, index) => <div>{item().name}</div>}
      </Index>
    </div>
  );
}

3. エラーハンドリングの最適化

typescriptimport { ErrorBoundary } from 'solid-js';

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

  // エラーが発生する可能性のあるコンポーネント
  const ProblematicComponent = () => {
    if (count() > 5) {
      throw new Error(`Count too high: ${count()}`);
    }
    return <div>Count: {count()}</div>;
  };

  return (
    <div>
      <ErrorBoundary
        fallback={(err) => <div>Error: {err.message}</div>}
      >
        <ProblematicComponent />
      </ErrorBoundary>

      <button onClick={() => setCount(count() + 1)}>
        Increment
      </button>
    </div>
  );
}

このように、SolidJS の特性を理解し、適切な最適化手法を適用することで、高パフォーマンスなアプリケーションを構築できます。次のセクションでは、実践的なコンポーネント例を通じて、これらのベストプラクティスを具体的に見ていきましょう。

実践的なコンポーネント例

Button コンポーネント

再利用可能で拡張性の高い Button コンポーネントを作成してみましょう。このコンポーネントは、様々な場面で使用できるよう設計されています。

まず、Button コンポーネントの基本的な型定義から始めます:

typescriptimport { JSX, mergeProps, splitProps } from 'solid-js';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'outline';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  loading?: boolean;
  leftIcon?: JSX.Element;
  rightIcon?: JSX.Element;
  onClick?: (event: MouseEvent) => void;
  children: JSX.Element;
  class?: string;
  type?: 'button' | 'submit' | 'reset';
}

次に、実際の Button コンポーネントの実装です:

typescriptfunction Button(props: ButtonProps) {
  // デフォルト値の設定
  const merged = mergeProps(
    {
      variant: 'primary' as const,
      size: 'medium' as const,
      disabled: false,
      loading: false,
      type: 'button' as const,
    },
    props
  );

  // propsの分割(HTML属性とカスタムpropsを分離)
  const [local, others] = splitProps(merged, [
    'variant',
    'size',
    'disabled',
    'loading',
    'leftIcon',
    'rightIcon',
    'onClick',
    'children',
    'class',
  ]);

  // CSSクラスの動的生成
  const buttonClass = () => {
    const baseClass = 'btn';
    const variantClass = `btn-${local.variant}`;
    const sizeClass = `btn-${local.size}`;
    const disabledClass =
      local.disabled || local.loading ? 'btn-disabled' : '';
    const loadingClass = local.loading ? 'btn-loading' : '';
    const customClass = local.class || '';

    return [
      baseClass,
      variantClass,
      sizeClass,
      disabledClass,
      loadingClass,
      customClass,
    ]
      .filter(Boolean)
      .join(' ');
  };

  // クリックハンドラー
  const handleClick = (event: MouseEvent) => {
    if (local.disabled || local.loading) {
      event.preventDefault();
      return;
    }
    local.onClick?.(event);
  };

  return (
    <button
      {...others}
      class={buttonClass()}
      disabled={local.disabled || local.loading}
      onClick={handleClick}
    >
      {local.leftIcon && (
        <span class='btn-icon-left'>{local.leftIcon}</span>
      )}
      {local.loading && <span class='btn-spinner'></span>}
      <span class='btn-text'>{local.children}</span>
      {local.rightIcon && (
        <span class='btn-icon-right'>
          {local.rightIcon}
        </span>
      )}
    </button>
  );
}

使用例とよくあるエラーの対処法:

typescript// ✅ 基本的な使用
function ButtonExample() {
  const [isLoading, setIsLoading] = createSignal(false);

  const handleSubmit = async () => {
    setIsLoading(true);
    try {
      await new Promise((resolve) =>
        setTimeout(resolve, 2000)
      );
      console.log('送信完了');
    } catch (error) {
      console.error('送信エラー:', error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <Button
        variant='primary'
        size='medium'
        loading={isLoading()}
        onClick={handleSubmit}
      >
        送信
      </Button>
    </div>
  );
}

Form コンポーネント

フォームの状態管理とバリデーションを統合した Form コンポーネントを作成します。このコンポーネントは、複雑なフォーム処理を簡潔に記述できるよう設計されています。

まず、フォームのバリデーション機能を実装します:

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

interface FormField {
  value: string;
  error?: string;
  touched: boolean;
}

interface ValidationRule {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: RegExp;
  custom?: (value: string) => string | undefined;
}

interface FormConfig {
  [key: string]: ValidationRule;
}

function createFormStore(config: FormConfig) {
  const [fields, setFields] = createStore<
    Record<string, FormField>
  >({});

  // フィールドの初期化
  const initializeField = (
    name: string,
    initialValue = ''
  ) => {
    if (!fields[name]) {
      setFields(name, {
        value: initialValue,
        error: undefined,
        touched: false,
      });
    }
  };

  // バリデーション関数
  const validateField = (
    name: string,
    value: string
  ): string | undefined => {
    const rules = config[name];
    if (!rules) return undefined;

    if (rules.required && !value.trim()) {
      return 'この項目は必須です';
    }

    if (rules.minLength && value.length < rules.minLength) {
      return `最低${rules.minLength}文字以上入力してください`;
    }

    if (rules.maxLength && value.length > rules.maxLength) {
      return `最大${rules.maxLength}文字以下で入力してください`;
    }

    if (rules.pattern && !rules.pattern.test(value)) {
      return '形式が正しくありません';
    }

    if (rules.custom) {
      return rules.custom(value);
    }

    return undefined;
  };

  return {
    fields,
    setFields,
    initializeField,
    validateField,
  };
}

次に、実際の Form コンポーネントの実装です:

typescriptinterface FormProps {
  onSubmit: (
    data: Record<string, string>
  ) => void | Promise<void>;
  validationConfig: FormConfig;
  children: any;
  class?: string;
}

function Form(props: FormProps) {
  const {
    fields,
    setFields,
    initializeField,
    validateField,
  } = createFormStore(props.validationConfig);
  const [isSubmitting, setIsSubmitting] =
    createSignal(false);

  // フォーム全体の妥当性を計算
  const isFormValid = createMemo(() => {
    const fieldNames = Object.keys(props.validationConfig);
    return fieldNames.every((name) => {
      const field = fields[name];
      return field && !field.error && field.value.trim();
    });
  });

  // フィールド値の更新
  const updateField = (name: string, value: string) => {
    initializeField(name);
    const error = validateField(name, value);
    setFields(name, { value, error, touched: true });
  };

  // フォーム送信処理
  const handleSubmit = async (event: Event) => {
    event.preventDefault();

    if (!isFormValid()) {
      console.error('Form validation failed');
      return;
    }

    setIsSubmitting(true);
    try {
      const formData = Object.keys(fields).reduce(
        (acc, key) => {
          acc[key] = fields[key].value;
          return acc;
        },
        {} as Record<string, string>
      );

      await props.onSubmit(formData);
    } catch (error) {
      console.error('Form submission failed:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} class={props.class}>
      {props.children({
        fields,
        updateField,
        isSubmitting: isSubmitting(),
        isValid: isFormValid(),
      })}
    </form>
  );
}

FormInput コンポーネントの実装:

typescriptinterface FormInputProps {
  name: string;
  type?: 'text' | 'email' | 'password' | 'tel';
  placeholder?: string;
  label?: string;
  field?: FormField;
  onInput?: (name: string, value: string) => void;
}

function FormInput(props: FormInputProps) {
  const merged = mergeProps(
    {
      type: 'text' as const,
      placeholder: '',
    },
    props
  );

  return (
    <div class='form-group'>
      {merged.label && (
        <label class='form-label' for={merged.name}>
          {merged.label}
        </label>
      )}

      <input
        id={merged.name}
        name={merged.name}
        type={merged.type}
        placeholder={merged.placeholder}
        value={merged.field?.value || ''}
        onInput={(e) =>
          merged.onInput?.(merged.name, e.target.value)
        }
        class={`form-input ${
          merged.field?.error ? 'form-input-error' : ''
        }`}
      />

      {merged.field?.error && merged.field.touched && (
        <span class='form-error'>{merged.field.error}</span>
      )}
    </div>
  );
}

List コンポーネント

動的なリストを効率的に表示する List コンポーネントを作成します。このコンポーネントは、大量のデータを扱う際のパフォーマンスを考慮した設計になっています。

まず、基本的な List コンポーネントの実装:

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

interface ListItem {
  id: string | number;
  [key: string]: any;
}

interface ListProps<T extends ListItem> {
  items: T[];
  renderItem: (item: T, index: number) => JSX.Element;
  keyField?: keyof T;
  emptyMessage?: string;
  loading?: boolean;
  onItemClick?: (item: T, index: number) => void;
  class?: string;
}

function List<T extends ListItem>(props: ListProps<T>) {
  const merged = mergeProps(
    {
      keyField: 'id' as keyof T,
      emptyMessage: '項目がありません',
      loading: false,
    },
    props
  );

  // アイテムが空の場合の表示
  const isEmpty = createMemo(
    () => merged.items.length === 0
  );

  // ローディング状態の表示
  if (merged.loading) {
    return (
      <div class={`list ${merged.class || ''}`}>
        <div class='list-loading'>読み込み中...</div>
      </div>
    );
  }

  // 空の場合の表示
  if (isEmpty()) {
    return (
      <div class={`list ${merged.class || ''}`}>
        <div class='list-empty'>{merged.emptyMessage}</div>
      </div>
    );
  }

  return (
    <div class={`list ${merged.class || ''}`}>
      <For each={merged.items}>
        {(item, index) => (
          <div
            class='list-item'
            onClick={() =>
              merged.onItemClick?.(item, index())
            }
            data-id={item[merged.keyField]}
          >
            {merged.renderItem(item, index())}
          </div>
        )}
      </For>
    </div>
  );
}

仮想化された List コンポーネント(大量データ対応):

typescriptinterface VirtualizedListProps<T extends ListItem>
  extends ListProps<T> {
  itemHeight: number;
  containerHeight: number;
  overscan?: number;
}

function VirtualizedList<T extends ListItem>(
  props: VirtualizedListProps<T>
) {
  const merged = mergeProps(
    {
      overscan: 5,
    },
    props
  );

  const [scrollTop, setScrollTop] = createSignal(0);

  // 表示範囲の計算
  const visibleRange = createMemo(() => {
    const start = Math.floor(
      scrollTop() / merged.itemHeight
    );
    const end = Math.min(
      start +
        Math.ceil(
          merged.containerHeight / merged.itemHeight
        ) +
        merged.overscan,
      merged.items.length
    );

    return {
      start: Math.max(0, start - merged.overscan),
      end,
    };
  });

  // 表示するアイテム
  const visibleItems = createMemo(() => {
    const range = visibleRange();
    return merged.items.slice(range.start, range.end);
  });

  // スクロールハンドラー
  const handleScroll = (e: Event) => {
    const target = e.target as HTMLElement;
    setScrollTop(target.scrollTop);
  };

  return (
    <div
      class={`virtualized-list ${merged.class || ''}`}
      style={{
        height: `${merged.containerHeight}px`,
        overflow: 'auto',
      }}
      onScroll={handleScroll}
    >
      <div
        style={{
          height: `${
            merged.items.length * merged.itemHeight
          }px`,
          position: 'relative',
        }}
      >
        <For each={visibleItems()}>
          {(item, index) => {
            const actualIndex =
              visibleRange().start + index();
            return (
              <div
                class='virtualized-list-item'
                style={{
                  position: 'absolute',
                  top: `${
                    actualIndex * merged.itemHeight
                  }px`,
                  height: `${merged.itemHeight}px`,
                  width: '100%',
                }}
                onClick={() =>
                  merged.onItemClick?.(item, actualIndex)
                }
              >
                {merged.renderItem(item, actualIndex)}
              </div>
            );
          }}
        </For>
      </div>
    </div>
  );
}

実際の使用例:

typescriptfunction ListExample() {
  const [users, setUsers] = createStore([
    {
      id: 1,
      name: '田中太郎',
      email: 'tanaka@example.com',
    },
    { id: 2, name: '佐藤花子', email: 'sato@example.com' },
    {
      id: 3,
      name: '鈴木一郎',
      email: 'suzuki@example.com',
    },
  ]);

  const [isLoading, setIsLoading] = createSignal(false);

  const handleUserClick = (
    user: (typeof users)[0],
    index: number
  ) => {
    console.log(
      'ユーザーがクリックされました:',
      user,
      'インデックス:',
      index
    );
  };

  return (
    <div>
      <List
        items={users}
        loading={isLoading()}
        onItemClick={handleUserClick}
        renderItem={(user, index) => (
          <div class='user-card'>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </div>
        )}
      />
    </div>
  );
}

これらの実践的なコンポーネント例では、SolidJS の特徴を活かしながら、再利用性とパフォーマンスを両立させる設計手法を示しています。各コンポーネントは型安全性を保ちながら、柔軟な拡張が可能な構造になっているのです。

まとめ

SolidJS のコンポーネント設計において、最も重要なのはシグナルベースの状態管理を理解し、効果的に活用することです。今回ご紹介した内容を振り返ってみましょう。

学習のポイント

項目重要度主なメリット
シグナルの適切な使用★★★★★細粒度な更新によるパフォーマンス向上
型安全な Props 設計★★★★☆開発時のエラー防止と保守性向上
コンポーネント分割戦略★★★★☆再利用性と可読性の向上
メモ化の活用★★★☆☆重い計算処理の最適化

実践で意識すべき点

SolidJS でのコンポーネント設計では、以下の点を常に意識することが重要です:

1. シグナルを関数として呼び出す React とは異なり、SolidJS ではシグナルは必ず関数として呼び出す必要があります。これを忘れるとTypeError: count is not a functionのようなエラーが発生してしまいます。

2. 適切な粒度での状態管理 大きなオブジェクトを一つのストアで管理するよりも、関連する状態をシグナルで細かく分割することで、より効率的な更新が可能になります。

3. TypeScript との組み合わせ 型安全性を活かすことで、開発時のエラーを大幅に減らすことができます。特に Props の型定義は、チーム開発での重要な指針となります。

今後の学習方向

SolidJS のコンポーネント設計をさらに深く理解するために、以下の分野も学習されることをお勧めします:

  • カスタムフックの作成: ロジックの再利用性を高める
  • Context の活用: グローバルな状態管理
  • Suspense との組み合わせ: 非同期処理の効率化
  • テスト戦略: 品質の高いコンポーネントの作成

SolidJS は、まだ新しいフレームワークですが、その設計思想は非常に洗練されています。適切なコンポーネント設計を身につけることで、高性能で保守性の高いアプリケーションを構築できるようになるでしょう。

皆さんの開発がより効率的で楽しいものになることを願っています!

関連リンク