T-CREATOR

SolidJS アプリのディレクトリ構成と設計原則

SolidJS アプリのディレクトリ構成と設計原則

SolidJS は高いパフォーマンスと優れた開発体験を提供するモダンなフロントエンドフレームワークです。しかし、その真価を発揮するためには、適切なディレクトリ構成と設計原則の理解が欠かせません。

本記事では、SolidJS アプリケーションにおける効果的なディレクトリ構成の方法と、実践的な設計原則について詳しく解説いたします。初心者の方から経験豊富な開発者まで、すぐに実践できる知識をお伝えします。

背景

SolidJS の特徴とディレクトリ構成の重要性

SolidJS は React と似た記法を持ちながら、仮想 DOM を使わないリアクティブシステムを採用しています。この特徴により、従来のフレームワークとは異なるアプローチでディレクトリ構成を考える必要があります。

mermaidflowchart LR
  component[コンポーネント] -->|リアクティブ| signal[Signal]
  signal -->|自動更新| dom[実DOM]
  component -->|直接操作| dom
  style signal fill:#e1f5fe
  style dom fill:#f3e5f5

SolidJS のリアクティブシステムでは、状態変更が直接 DOM に反映されるため、コンポーネントの責務分離がより重要になります。適切な構成により、このメリットを最大限活用できるでしょう。

React との違いと構成上の考慮点

React では仮想 DOM の diff による最適化が行われますが、SolidJS では Signal ベースの細粒度リアクティビティが採用されています。

項目ReactSolidJS
レンダリング仮想 DOM + diff直接 DOM 更新
状態管理useState, useEffectcreateSignal, createEffect
最適化React.memo, useMemo自動最適化
バンドルサイズ大きめ小さめ

この違いにより、SolidJS では以下の点を考慮した構成が必要です。

  • Signal の適切な配置と管理
  • エフェクトの依存関係の明確化
  • コンポーネントの再利用性の向上

パフォーマンスを意識した設計の必要性

SolidJS の優れたパフォーマンスを活かすためには、構成レベルでの配慮が重要です。

以下の図は、パフォーマンスに影響する構成要素を示しています。

mermaidflowchart TD
  app[アプリケーション] --> components[コンポーネント分割]
  app --> state[状態管理]
  app --> routing[ルーティング]
  
  components --> lazy[遅延読み込み]
  components --> memo[メモ化]
  
  state --> global[グローバル状態]
  state --> local[ローカル状態]
  
  routing --> code[コード分割]
  routing --> preload[プリロード]
  
  style components fill:#e8f5e8
  style state fill:#fff3e0
  style routing fill:#e3f2fd

図で理解できる要点:

  • コンポーネント分割により遅延読み込みとメモ化を効率化
  • 状態管理の適切な配置でパフォーマンス向上
  • ルーティング設計によるコード分割とプリロード最適化

課題

従来のフレームワークでの構成の問題点

多くの開発者が React や Vue.js の経験を SolidJS に持ち込むことで、以下のような問題が発生しています。

過度なコンポーネント分割 React の最適化手法をそのまま適用し、不要な細分化を行ってしまうケースが見られます。

typescript// 問題のある例:React の思考で作られた過度な分割
const UserCard = (props) => {
  return (
    <div>
      <UserAvatar user={props.user} />
      <UserName user={props.user} />
      <UserEmail user={props.user} />
    </div>
  );
};

状態管理の混乱 React の useState パターンを無理に適用し、Signal の恩恵を受けられない構成になってしまいます。

SolidJS 特有の構成課題

Signal の管理範囲の曖昧さ どこで Signal を定義し、どのように共有するかの指針が不明確になりがちです。

typescript// よくない例:Signal の責務が不明確
const [userData, setUserData] = createSignal();
const [userSettings, setUserSettings] = createSignal();
const [userPermissions, setUserPermissions] = createSignal();

リアクティブ依存関係の複雑化 Effect の依存関係が複雑になり、意図しない更新が発生する問題があります。

チーム開発における構成統一の難しさ

開発チーム内でのディレクトリ構成の統一は、プロジェクトの成功に直結する重要な要素です。

課題影響対策の必要性
命名規則の不統一可読性低下
ファイル配置の曖昧さ保守性悪化
責務分離の不明確さバグ増加

解決策

SolidJS 推奨ディレクトリ構成

実践的で保守性の高いディレクトリ構成をご紹介します。

bashsrc/
├── components/          # 再利用可能なコンポーネント
│   ├── ui/             # UI コンポーネント
│   ├── forms/          # フォーム関連
│   └── layout/         # レイアウト関連
├── pages/              # ページコンポーネント
├── stores/             # 状態管理
├── utils/              # ユーティリティ関数
├── types/              # TypeScript 型定義
├── hooks/              # カスタムフック
├── services/           # API 通信
└── assets/             # 静的ファイル

この構成では、各ディレクトリが明確な責務を持ち、開発者が迷わずにファイルを配置できます。

Feature-based vs Layer-based アプローチ

Layer-based アプローチ(技術層による分割)

csssrc/
├── components/
├── services/
├── stores/
└── utils/

小規模〜中規模プロジェクトに適しており、技術的な関心事で分離されています。

Feature-based アプローチ(機能による分割)

csssrc/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── stores/
│   │   └── services/
│   └── dashboard/
│       ├── components/
│       ├── stores/
│       └── services/
└── shared/
    ├── components/
    ├── utils/
    └── types/

大規模プロジェクトや複数チーム開発に適しており、ビジネス機能単位で分離されています。

設計原則とベストプラクティス

単一責任の原則 各コンポーネントは一つの明確な責任を持つべきです。

typescript// 良い例:単一責任を持つコンポーネント
const UserAvatar = (props: { user: User; size: 'sm' | 'md' | 'lg' }) => {
  return (
    <img 
      src={props.user.avatar} 
      alt={`${props.user.name}のアバター`}
      class={`avatar avatar-${props.size}`}
    />
  );
};

Signal の適切な配置 Signal は使用される最小のスコープで定義し、必要に応じて上位に移動させます。

typescript// コンポーネントレベルの Signal
const TodoItem = () => {
  const [completed, setCompleted] = createSignal(false);
  
  return (
    <div class={`todo-item ${completed() ? 'completed' : ''}`}>
      {/* コンポーネント内容 */}
    </div>
  );
};

依存関係の明確化 Effect の依存関係を明確にし、予期しない更新を防ぎます。

typescript// 依存関係が明確な Effect
createEffect(() => {
  const user = currentUser();
  const settings = userSettings();
  
  if (user && settings) {
    updateUserPreferences(user.id, settings);
  }
});

具体例

小規模アプリの構成例

Todo アプリを例に、小規模アプリケーションの構成を示します。

javatodo-app/
├── src/
│   ├── components/
│   │   ├── TodoItem.tsx
│   │   ├── TodoList.tsx
│   │   └── AddTodo.tsx
│   ├── stores/
│   │   └── todoStore.ts
│   ├── types/
│   │   └── todo.ts
│   ├── App.tsx
│   └── index.tsx
├── public/
└── package.json

Todo の型定義

typescript// types/todo.ts
export interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
}

Store の実装

typescript// stores/todoStore.ts
import { createSignal } from 'solid-js';
import type { Todo } from '../types/todo';

export const [todos, setTodos] = createSignal<Todo[]>([]);

export const addTodo = (text: string) => {
  const newTodo: Todo = {
    id: crypto.randomUUID(),
    text,
    completed: false,
    createdAt: new Date()
  };
  
  setTodos(prev => [...prev, newTodo]);
};

コンポーネントの実装

typescript// components/TodoItem.tsx
import { Todo } from '../types/todo';

interface TodoItemProps {
  todo: Todo;
  onToggle: (id: string) => void;
}

export const TodoItem = (props: TodoItemProps) => {
  return (
    <div class={`todo-item ${props.todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={props.todo.completed}
        onChange={() => props.onToggle(props.todo.id)}
      />
      <span class="todo-text">{props.todo.text}</span>
    </div>
  );
};

中規模〜大規模アプリの構成例

E コマースアプリケーションを例に、Feature-based アプローチの構成を示します。

cssecommerce-app/
├── src/
│   ├── features/
│   │   ├── auth/
│   │   │   ├── components/
│   │   │   │   ├── LoginForm.tsx
│   │   │   │   └── SignupForm.tsx
│   │   │   ├── stores/
│   │   │   │   └── authStore.ts
│   │   │   ├── services/
│   │   │   │   └── authApi.ts
│   │   │   └── types/
│   │   │       └── auth.ts
│   │   ├── products/
│   │   │   ├── components/
│   │   │   ├── stores/
│   │   │   ├── services/
│   │   │   └── types/
│   │   └── cart/
│   │       ├── components/
│   │       ├── stores/
│   │       ├── services/
│   │       └── types/
│   ├── shared/
│   │   ├── components/
│   │   │   ├── ui/
│   │   │   └── layout/
│   │   ├── utils/
│   │   ├── hooks/
│   │   └── types/
│   ├── pages/
│   └── App.tsx
└── package.json

認証機能の Store

typescript// features/auth/stores/authStore.ts
import { createSignal, createEffect } from 'solid-js';
import type { User } from '../types/auth';
import { authApi } from '../services/authApi';

export const [user, setUser] = createSignal<User | null>(null);
export const [isLoading, setIsLoading] = createSignal(false);

export const login = async (email: string, password: string) => {
  setIsLoading(true);
  try {
    const userData = await authApi.login({ email, password });
    setUser(userData);
    localStorage.setItem('authToken', userData.token);
  } catch (error) {
    console.error('ログインエラー:', error);
    throw error;
  } finally {
    setIsLoading(false);
  }
};

共有 UI コンポーネント

typescript// shared/components/ui/Button.tsx
import { JSX, splitProps } from 'solid-js';

interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
}

export const Button = (props: ButtonProps) => {
  const [local, others] = splitProps(props, ['variant', 'size', 'class']);
  
  const baseClass = 'btn';
  const variantClass = `btn-${local.variant || 'primary'}`;
  const sizeClass = `btn-${local.size || 'md'}`;
  
  return (
    <button
      class={`${baseClass} ${variantClass} ${sizeClass} ${local.class || ''}`}
      {...others}
    />
  );
};

各ディレクトリの役割と実装例

components ディレクトリ 再利用可能な UI コンポーネントを配置します。

typescript// components/ui/Modal.tsx
import { JSX, Show, createEffect } from 'solid-js';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  children: JSX.Element;
}

export const Modal = (props: ModalProps) => {
  let dialogRef: HTMLDialogElement;

  createEffect(() => {
    if (props.isOpen) {
      dialogRef?.showModal();
    } else {
      dialogRef?.close();
    }
  });

  return (
    <dialog
      ref={dialogRef!}
      class="modal"
      onClick={(e) => e.target === dialogRef && props.onClose()}
    >
      <div class="modal-content">
        <Show when={props.title}>
          <header class="modal-header">
            <h2>{props.title}</h2>
            <button class="modal-close" onClick={props.onClose}>
              ×
            </button>
          </header>
        </Show>
        <main class="modal-body">
          {props.children}
        </main>
      </div>
    </dialog>
  );
};

stores ディレクトリ アプリケーションの状態管理を担当します。

typescript// stores/appStore.ts
import { createSignal, createContext, useContext, JSX } from 'solid-js';

interface AppState {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  isLoading: boolean;
}

const AppContext = createContext<{
  state: () => AppState;
  setTheme: (theme: AppState['theme']) => void;
  setLanguage: (language: AppState['language']) => void;
  setLoading: (loading: boolean) => void;
}>();

export const AppProvider = (props: { children: JSX.Element }) => {
  const [state, setState] = createSignal<AppState>({
    theme: 'light',
    language: 'ja',
    isLoading: false
  });

  const setTheme = (theme: AppState['theme']) => {
    setState(prev => ({ ...prev, theme }));
  };

  const setLanguage = (language: AppState['language']) => {
    setState(prev => ({ ...prev, language }));
  };

  const setLoading = (isLoading: boolean) => {
    setState(prev => ({ ...prev, isLoading }));
  };

  return (
    <AppContext.Provider value={{ state, setTheme, setLanguage, setLoading }}>
      {props.children}
    </AppContext.Provider>
  );
};

export const useApp = () => {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp は AppProvider 内で使用してください');
  }
  return context;
};

services ディレクトリ API 通信やビジネスロジックを配置します。

typescript// services/apiClient.ts
class ApiClient {
  private baseURL: string;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      headers: this.getHeaders()
    });
    
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }
    
    return response.json();
  }

  async post<T>(endpoint: string, data: unknown): Promise<T> {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'POST',
      headers: this.getHeaders(),
      body: JSON.stringify(data)
    });
    
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }
    
    return response.json();
  }

  private getHeaders() {
    const token = localStorage.getItem('authToken');
    return {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` })
    };
  }
}

export const apiClient = new ApiClient(import.meta.env.VITE_API_URL);

図で理解できる要点:

  • 各ディレクトリが明確な責務を持ち、依存関係が整理されている
  • 共有コンポーネントと機能固有コンポーネントが適切に分離されている
  • 状態管理とビジネスロジックが適切に分離され、テスト可能な構成になっている

まとめ

SolidJS アプリケーションの適切なディレクトリ構成と設計原則について解説いたしました。

重要なポイントをまとめると以下の通りです。

SolidJS の特性を活かした構成

  • Signal ベースのリアクティブシステムを考慮した状態管理の配置
  • コンポーネントの適切な責務分離による保守性の向上
  • パフォーマンスを意識した構成設計

実践的なアプローチ

  • プロジェクト規模に応じた柔軟な構成選択
  • Feature-based と Layer-based の使い分け
  • チーム開発での統一性確保

継続的な改善

  • プロジェクトの成長に合わせた構成の見直し
  • 設計原則に基づく定期的なリファクタリング
  • パフォーマンス測定による最適化

適切なディレクトリ構成は、開発効率とコード品質の向上に直結します。本記事でご紹介した手法を参考に、皆様のプロジェクトに最適な構成を見つけていただければと思います。

継続的な学習と改善により、より良い SolidJS アプリケーションを構築していきましょう。

関連リンク