T-CREATOR

Emotion で B2B 管理画面を高速構築:テーブル/フィルタ/フォームの型安全化

Emotion で B2B 管理画面を高速構築:テーブル/フィルタ/フォームの型安全化

B2B 向けの管理画面開発では、データテーブル、フィルタ機能、フォームコンポーネントを高速に実装しながらも、型安全性を保つことが重要です。 本記事では、CSS-in-JS ライブラリである Emotion を活用し、TypeScript の型システムと組み合わせて、保守性の高い管理画面を構築する方法を解説します。スタイリングの柔軟性と型安全性を両立させることで、開発効率と品質を同時に向上させられるのです。

背景

管理画面開発における課題

B2B 向けの管理画面では、複数のデータテーブルやフィルタ、入力フォームなど、多数のコンポーネントを実装する必要があります。 これらのコンポーネントは似た構造を持ちながらも、それぞれ異なるデータ型や表示要件を持つため、コードの重複や型の不整合が発生しやすいのです。

従来の CSS ファイルによるスタイリングでは、クラス名の衝突やグローバルスコープの汚染が問題となり、大規模なプロジェクトでは保守性が低下します。 また、コンポーネントとスタイルが分離されているため、変更時の影響範囲を把握しづらく、リファクタリングの難易度も高くなるでしょう。

Emotion が選ばれる理由

Emotion は CSS-in-JS のライブラリで、JavaScript(TypeScript)内でスタイルを記述できます。 コンポーネントとスタイルを同じファイルで管理できるため、コードの見通しが良くなり、TypeScript の型システムとも相性が良いのです。

以下の図は、Emotion を用いた管理画面の基本的なアーキテクチャを示しています。

mermaidflowchart TB
  user["管理者ユーザー"] -->|操作| ui["UI Layer<br/>(Emotion styled)"]
  ui -->|イベント| logic["ロジック層<br/>(TypeScript)"]
  logic -->|型安全な<br/>データ操作| state["状態管理<br/>(React State)"]
  state -->|レンダリング| ui
  logic -->|API リクエスト| api["Backend API"]
  api -->|JSON| logic

この構成により、スタイルとロジックが密結合しつつ、型安全性が保たれます。

TypeScript による型安全性の重要性

管理画面では、異なるエンティティ(ユーザー、商品、注文など)を扱うため、各データ型を厳密に定義する必要があります。 TypeScript の型システムを活用することで、コンパイル時にエラーを検出し、実行時のバグを未然に防げるのです。

Emotion と TypeScript を組み合わせることで、スタイルのプロパティにも型チェックを適用でき、開発体験が大幅に向上します。 さらに、IDE の補完機能を活用できるため、開発速度も向上するでしょう。

課題

スタイリングの一貫性とカスタマイズ性の両立

管理画面では、統一されたデザインシステムを適用しながらも、個別のコンポーネントごとにカスタマイズが必要になります。 グローバルな CSS では一貫性を保ちやすい反面、細かなカスタマイズが難しく、CSS Modules では逆にグローバルなテーマの適用が煩雑になってしまうのです。

Emotion では、テーマプロバイダーを用いることで、グローバルなデザイントークンとコンポーネント固有のスタイルを両立できます。

型安全なテーブルコンポーネントの実装

データテーブルは、管理画面の中核となるコンポーネントです。 異なるデータ型に対応しながら、列定義やソート、ページネーションなどの機能を型安全に実装する必要があります。

以下の図は、テーブルコンポーネントにおける型の流れを示しています。

mermaidflowchart LR
  dataType["データ型定義<br/>(interface User)"] -->|ジェネリクス| table["Table Component<br/>&lt;T&gt;"]
  columnDef["列定義<br/>(Column&lt;T&gt;[])"] -->|プロパティ| table
  table -->|map関数| row["各行レンダリング"]
  row -->|型推論| cell["セルコンポーネント"]
  cell -->|Emotionスタイル| styled["スタイル適用済みUI"]

ジェネリック型を活用することで、どのようなデータ型にも対応できる汎用的なテーブルコンポーネントを作成できます。

フィルタとフォームの動的な生成

管理画面では、検索フィルタや入力フォームを動的に生成する必要があります。 フィルタ条件やフォームフィールドは、エンティティごとに異なるため、型定義から自動的にコンポーネントを生成できると理想的です。

しかし、型情報はコンパイル時にしか存在しないため、実行時に型から UI を生成することはできません。 そこで、型定義とメタデータを組み合わせて、型安全な動的コンポーネント生成を実現する必要があるのです。

パフォーマンスとバンドルサイズの最適化

CSS-in-JS は便利ですが、実行時にスタイルを生成するため、パフォーマンスへの影響が懸念されます。 特に、大量のデータを表示するテーブルでは、レンダリングパフォーマンスが重要になるでしょう。

Emotion は、スタイルのキャッシング機構や CSS の抽出機能を持っているため、適切に使用すればパフォーマンス問題を最小限に抑えられます。

解決策

Emotion のセットアップと基本設定

まず、プロジェクトに Emotion をインストールします。 React 環境では、@emotion​/​react@emotion​/​styled を使用するのが一般的です。

typescript// package.json への追加(yarn add で実行)
// yarn add @emotion/react @emotion/styled
// yarn add -D @emotion/babel-plugin

次に、TypeScript の設定を行います。 tsconfig.json に JSX の設定を追加し、Emotion の型定義を有効にします。

json{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@emotion/react",
    "types": ["@emotion/react/types/css-prop"]
  }
}

この設定により、css prop を TypeScript で使用できるようになります。

テーマシステムの構築

管理画面全体で一貫したデザインを適用するため、テーマシステムを構築します。 まず、テーマの型定義を作成しましょう。

typescript// types/theme.ts

// カラーパレットの定義
interface ColorPalette {
  primary: string;
  secondary: string;
  success: string;
  warning: string;
  error: string;
  background: string;
  surface: string;
  text: string;
  textSecondary: string;
  border: string;
}
typescript// types/theme.ts(続き)

// スペーシングの定義
interface Spacing {
  xs: string;
  sm: string;
  md: string;
  lg: string;
  xl: string;
}
typescript// types/theme.ts(続き)

// タイポグラフィの定義
interface Typography {
  fontFamily: string;
  fontSize: {
    xs: string;
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
  fontWeight: {
    regular: number;
    medium: number;
    bold: number;
  };
}
typescript// types/theme.ts(続き)

// 全体のテーマ型
export interface Theme {
  colors: ColorPalette;
  spacing: Spacing;
  typography: Typography;
  borderRadius: string;
  shadows: {
    sm: string;
    md: string;
    lg: string;
  };
}

続いて、実際のテーマオブジェクトを作成します。

typescript// theme/default.ts

import { Theme } from '../types/theme';

export const defaultTheme: Theme = {
  colors: {
    primary: '#1976d2',
    secondary: '#dc004e',
    success: '#4caf50',
    warning: '#ff9800',
    error: '#f44336',
    background: '#f5f5f5',
    surface: '#ffffff',
    text: '#333333',
    textSecondary: '#666666',
    border: '#e0e0e0',
  },
typescript// theme/default.ts(続き)

  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
  },
typescript// theme/default.ts(続き)

  typography: {
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
    fontSize: {
      xs: '12px',
      sm: '14px',
      md: '16px',
      lg: '18px',
      xl: '24px',
    },
    fontWeight: {
      regular: 400,
      medium: 500,
      bold: 700,
    },
  },
typescript// theme/default.ts(続き)

  borderRadius: '4px',
  shadows: {
    sm: '0 1px 3px rgba(0,0,0,0.12)',
    md: '0 2px 6px rgba(0,0,0,0.16)',
    lg: '0 4px 12px rgba(0,0,0,0.20)',
  },
};

ThemeProvider を使用して、アプリケーション全体にテーマを適用します。

typescript// App.tsx

import { ThemeProvider } from '@emotion/react';
import { defaultTheme } from './theme/default';

function App() {
  return (
    <ThemeProvider theme={defaultTheme}>
      {/* アプリケーションのコンポーネント */}
    </ThemeProvider>
  );
}

export default App;

これで、すべてのコンポーネントから useTheme フックを使用してテーマにアクセスできます。

型安全なテーブルコンポーネントの実装

ジェネリック型を活用した、再利用可能なテーブルコンポーネントを作成します。 まず、列定義の型を定義しましょう。

typescript// components/Table/types.ts

// 列定義の型
export interface Column<T> {
  key: keyof T;
  header: string;
  width?: string;
  render?: (value: T[keyof T], row: T) => React.ReactNode;
  sortable?: boolean;
}
typescript// components/Table/types.ts(続き)

// テーブルのプロパティ型
export interface TableProps<T> {
  data: T[];
  columns: Column<T>[];
  onRowClick?: (row: T) => void;
  loading?: boolean;
}

次に、テーブルのスタイルコンポーネントを作成します。

typescript// components/Table/styles.ts

import styled from '@emotion/styled';
import { Theme } from '../../types/theme';

// テーブル全体のコンテナ
export const TableContainer = styled.div`
  width: 100%;
  overflow-x: auto;
  background: ${({ theme }: { theme: Theme }) =>
    theme.colors.surface};
  border-radius: ${({ theme }) => theme.borderRadius};
  box-shadow: ${({ theme }) => theme.shadows.sm};
`;
typescript// components/Table/styles.ts(続き)

// テーブル本体
export const StyledTable = styled.table`
  width: 100%;
  border-collapse: collapse;

  th,
  td {
    padding: ${({ theme }: { theme: Theme }) =>
      theme.spacing.md};
    text-align: left;
    border-bottom: 1px solid ${({ theme }) =>
        theme.colors.border};
  }
`;
typescript// components/Table/styles.ts(続き)

// テーブルヘッダー
export const TableHeader = styled.th<{
  sortable?: boolean;
}>`
  font-weight: ${({ theme }: { theme: Theme }) =>
    theme.typography.fontWeight.bold};
  color: ${({ theme }) => theme.colors.text};
  background: ${({ theme }) => theme.colors.background};
  cursor: ${({ sortable }) =>
    sortable ? 'pointer' : 'default'};
  user-select: none;

  &:hover {
    background: ${({ theme, sortable }) =>
      sortable
        ? theme.colors.border
        : theme.colors.background};
  }
`;
typescript// components/Table/styles.ts(続き)

// テーブルセル
export const TableCell = styled.td`
  font-size: ${({ theme }: { theme: Theme }) =>
    theme.typography.fontSize.sm};
  color: ${({ theme }) => theme.colors.text};
`;
typescript// components/Table/styles.ts(続き)

// テーブル行(クリック可能な場合)
export const TableRow = styled.tr<{ clickable?: boolean }>`
  cursor: ${({ clickable }) =>
    clickable ? 'pointer' : 'default'};
  transition: background-color 0.2s;

  &:hover {
    background: ${({
      theme,
      clickable,
    }: {
      theme: Theme;
      clickable?: boolean;
    }) =>
      clickable ? theme.colors.background : 'transparent'};
  }
`;

テーブルコンポーネント本体を実装します。

typescript// components/Table/Table.tsx

import React from 'react';
import { TableProps } from './types';
import {
  TableContainer,
  StyledTable,
  TableHeader,
  TableCell,
  TableRow,
} from './styles';

export function Table<T extends Record<string, any>>({
  data,
  columns,
  onRowClick,
  loading = false,
}: TableProps<T>) {
  return (
    <TableContainer>
      <StyledTable>
        <thead>
          <tr>
            {columns.map((column) => (
              <TableHeader
                key={String(column.key)}
                sortable={column.sortable}
                style={{ width: column.width }}
              >
                {column.header}
              </TableHeader>
            ))}
          </tr>
        </thead>
typescript// components/Table/Table.tsx(続き)

        <tbody>
          {data.map((row, index) => (
            <TableRow
              key={index}
              clickable={!!onRowClick}
              onClick={() => onRowClick?.(row)}
            >
              {columns.map((column) => (
                <TableCell key={String(column.key)}>
                  {column.render
                    ? column.render(row[column.key], row)
                    : String(row[column.key])}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </tbody>
      </StyledTable>
    </TableContainer>
  );
}

このテーブルコンポーネントは、ジェネリック型 T を受け取ることで、どのようなデータ型にも対応できます。

型安全なフィルタコンポーネントの構築

フィルタ機能を実装するために、まずフィルタの型定義を行います。

typescript// components/Filter/types.ts

// フィルタの種類
export type FilterType =
  | 'text'
  | 'select'
  | 'date'
  | 'number';

// フィルタ定義
export interface FilterDefinition<T> {
  key: keyof T;
  label: string;
  type: FilterType;
  options?: Array<{
    value: string | number;
    label: string;
  }>;
  placeholder?: string;
}
typescript// components/Filter/types.ts(続き)

// フィルタの値を管理する型
export type FilterValues<T> = Partial<Record<keyof T, any>>;

// フィルタコンポーネントのプロパティ
export interface FilterProps<T> {
  filters: FilterDefinition<T>[];
  values: FilterValues<T>;
  onChange: (values: FilterValues<T>) => void;
  onApply: () => void;
  onReset: () => void;
}

フィルタのスタイルコンポーネントを作成します。

typescript// components/Filter/styles.ts

import styled from '@emotion/styled';
import { Theme } from '../../types/theme';

// フィルタ全体のコンテナ
export const FilterContainer = styled.div`
  display: flex;
  gap: ${({ theme }: { theme: Theme }) => theme.spacing.md};
  padding: ${({ theme }) => theme.spacing.md};
  background: ${({ theme }) => theme.colors.surface};
  border-radius: ${({ theme }) => theme.borderRadius};
  margin-bottom: ${({ theme }) => theme.spacing.md};
  flex-wrap: wrap;
`;
typescript// components/Filter/styles.ts(続き)

// フィルタフィールドのラッパー
export const FilterField = styled.div`
  display: flex;
  flex-direction: column;
  gap: ${({ theme }: { theme: Theme }) => theme.spacing.xs};
  min-width: 200px;
`;
typescript// components/Filter/styles.ts(続き)

// フィルタのラベル
export const FilterLabel = styled.label`
  font-size: ${({ theme }: { theme: Theme }) =>
    theme.typography.fontSize.sm};
  font-weight: ${({ theme }) =>
    theme.typography.fontWeight.medium};
  color: ${({ theme }) => theme.colors.text};
`;
typescript// components/Filter/styles.ts(続き)

// 入力フィールドの共通スタイル
export const Input = styled.input`
  padding: ${({ theme }: { theme: Theme }) =>
    theme.spacing.sm};
  border: 1px solid ${({ theme }) => theme.colors.border};
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.sm};

  &:focus {
    outline: none;
    border-color: ${({ theme }) => theme.colors.primary};
  }
`;
typescript// components/Filter/styles.ts(続き)

// セレクトボックスのスタイル
export const Select = styled.select`
  padding: ${({ theme }: { theme: Theme }) =>
    theme.spacing.sm};
  border: 1px solid ${({ theme }) => theme.colors.border};
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.sm};
  background: white;

  &:focus {
    outline: none;
    border-color: ${({ theme }) => theme.colors.primary};
  }
`;
typescript// components/Filter/styles.ts(続き)

// ボタンコンテナ
export const ButtonGroup = styled.div`
  display: flex;
  gap: ${({ theme }: { theme: Theme }) => theme.spacing.sm};
  align-items: flex-end;
`;
typescript// components/Filter/styles.ts(続き)

// ボタンの共通スタイル
export const Button = styled.button<{
  variant?: 'primary' | 'secondary';
}>`
  padding: ${({ theme }: { theme: Theme }) =>
    `${theme.spacing.sm} ${theme.spacing.md}`};
  border: none;
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.sm};
  font-weight: ${({ theme }) =>
    theme.typography.fontWeight.medium};
  cursor: pointer;
  transition: all 0.2s;

  background: ${({ theme, variant = 'primary' }) =>
    variant === 'primary'
      ? theme.colors.primary
      : theme.colors.surface};
  color: ${({ theme, variant = 'primary' }) =>
    variant === 'primary' ? 'white' : theme.colors.text};
  border: ${({ theme, variant }) =>
    variant === 'secondary'
      ? `1px solid ${theme.colors.border}`
      : 'none'};

  &:hover {
    opacity: 0.8;
  }
`;

フィルタコンポーネント本体を実装します。

typescript// components/Filter/Filter.tsx

import React from 'react';
import { FilterProps, FilterDefinition } from './types';
import {
  FilterContainer,
  FilterField,
  FilterLabel,
  Input,
  Select,
  ButtonGroup,
  Button,
} from './styles';

export function Filter<T extends Record<string, any>>({
  filters,
  values,
  onChange,
  onApply,
  onReset,
}: FilterProps<T>) {
  // 個別フィールドの変更ハンドラ
  const handleChange = (key: keyof T, value: any) => {
    onChange({ ...values, [key]: value });
  };
typescript// components/Filter/Filter.tsx(続き)

  // フィルタタイプに応じた入力コンポーネントをレンダリング
  const renderFilterInput = (filter: FilterDefinition<T>) => {
    const value = values[filter.key] ?? '';

    switch (filter.type) {
      case 'text':
        return (
          <Input
            type="text"
            value={value}
            onChange={(e) => handleChange(filter.key, e.target.value)}
            placeholder={filter.placeholder}
          />
        );
typescript// components/Filter/Filter.tsx(続き)

      case 'number':
        return (
          <Input
            type="number"
            value={value}
            onChange={(e) => handleChange(filter.key, e.target.value)}
            placeholder={filter.placeholder}
          />
        );
typescript// components/Filter/Filter.tsx(続き)

      case 'date':
        return (
          <Input
            type="date"
            value={value}
            onChange={(e) => handleChange(filter.key, e.target.value)}
          />
        );
typescript// components/Filter/Filter.tsx(続き)

      case 'select':
        return (
          <Select
            value={value}
            onChange={(e) => handleChange(filter.key, e.target.value)}
          >
            <option value="">すべて</option>
            {filter.options?.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </Select>
        );

      default:
        return null;
    }
  };
typescript// components/Filter/Filter.tsx(続き)

  return (
    <FilterContainer>
      {filters.map((filter) => (
        <FilterField key={String(filter.key)}>
          <FilterLabel>{filter.label}</FilterLabel>
          {renderFilterInput(filter)}
        </FilterField>
      ))}

      <ButtonGroup>
        <Button variant="primary" onClick={onApply}>
          検索
        </Button>
        <Button variant="secondary" onClick={onReset}>
          リセット
        </Button>
      </ButtonGroup>
    </FilterContainer>
  );
}

このフィルタコンポーネントも、ジェネリック型を使用することで、型安全な実装になっています。

型安全なフォームコンポーネントの実装

入力フォームを作成するために、まずフォームの型定義を行います。

typescript// components/Form/types.ts

// フォームフィールドの種類
export type FieldType =
  | 'text'
  | 'email'
  | 'password'
  | 'number'
  | 'textarea'
  | 'select'
  | 'checkbox';

// バリデーションルール
export interface ValidationRule {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: RegExp;
  custom?: (value: any) => string | undefined;
}
typescript// components/Form/types.ts(続き)

// フォームフィールド定義
export interface FieldDefinition<T> {
  name: keyof T;
  label: string;
  type: FieldType;
  placeholder?: string;
  options?: Array<{
    value: string | number;
    label: string;
  }>;
  validation?: ValidationRule;
}
typescript// components/Form/types.ts(続き)

// フォームのプロパティ
export interface FormProps<T> {
  fields: FieldDefinition<T>[];
  initialValues?: Partial<T>;
  onSubmit: (values: T) => void | Promise<void>;
  submitText?: string;
}

// フィールドのエラー管理
export type FormErrors<T> = Partial<
  Record<keyof T, string>
>;

フォームのスタイルコンポーネントを作成します。

typescript// components/Form/styles.ts

import styled from '@emotion/styled';
import { Theme } from '../../types/theme';

// フォーム全体のコンテナ
export const FormContainer = styled.form`
  display: flex;
  flex-direction: column;
  gap: ${({ theme }: { theme: Theme }) => theme.spacing.lg};
  padding: ${({ theme }) => theme.spacing.lg};
  background: ${({ theme }) => theme.colors.surface};
  border-radius: ${({ theme }) => theme.borderRadius};
  box-shadow: ${({ theme }) => theme.shadows.md};
`;
typescript// components/Form/styles.ts(続き)

// フォームフィールドのラッパー
export const FormField = styled.div`
  display: flex;
  flex-direction: column;
  gap: ${({ theme }: { theme: Theme }) => theme.spacing.xs};
`;
typescript// components/Form/styles.ts(続き)

// フォームラベル
export const FormLabel = styled.label<{
  required?: boolean;
}>`
  font-size: ${({ theme }: { theme: Theme }) =>
    theme.typography.fontSize.sm};
  font-weight: ${({ theme }) =>
    theme.typography.fontWeight.medium};
  color: ${({ theme }) => theme.colors.text};

  ${({ required }) =>
    required &&
    `
    &::after {
      content: ' *';
      color: red;
    }
  `}
`;
typescript// components/Form/styles.ts(続き)

// 入力フィールドの基本スタイル
export const FormInput = styled.input<{
  hasError?: boolean;
}>`
  padding: ${({ theme }: { theme: Theme }) =>
    theme.spacing.md};
  border: 1px solid ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.border};
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.md};

  &:focus {
    outline: none;
    border-color: ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.primary};
  }
`;
typescript// components/Form/styles.ts(続き)

// テキストエリアのスタイル
export const FormTextarea = styled.textarea<{
  hasError?: boolean;
}>`
  padding: ${({ theme }: { theme: Theme }) =>
    theme.spacing.md};
  border: 1px solid ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.border};
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.md};
  min-height: 100px;
  resize: vertical;
  font-family: ${({ theme }) =>
    theme.typography.fontFamily};

  &:focus {
    outline: none;
    border-color: ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.primary};
  }
`;
typescript// components/Form/styles.ts(続き)

// セレクトボックスのスタイル
export const FormSelect = styled.select<{
  hasError?: boolean;
}>`
  padding: ${({ theme }: { theme: Theme }) =>
    theme.spacing.md};
  border: 1px solid ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.border};
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.md};
  background: white;

  &:focus {
    outline: none;
    border-color: ${({ theme, hasError }) =>
      hasError ? theme.colors.error : theme.colors.primary};
  }
`;
typescript// components/Form/styles.ts(続き)

// エラーメッセージのスタイル
export const ErrorMessage = styled.span`
  font-size: ${({ theme }: { theme: Theme }) =>
    theme.typography.fontSize.xs};
  color: ${({ theme }) => theme.colors.error};
`;
typescript// components/Form/styles.ts(続き)

// 送信ボタンのスタイル
export const SubmitButton = styled.button`
  padding: ${({ theme }: { theme: Theme }) =>
    `${theme.spacing.md} ${theme.spacing.lg}`};
  background: ${({ theme }) => theme.colors.primary};
  color: white;
  border: none;
  border-radius: ${({ theme }) => theme.borderRadius};
  font-size: ${({ theme }) => theme.typography.fontSize.md};
  font-weight: ${({ theme }) =>
    theme.typography.fontWeight.medium};
  cursor: pointer;
  transition: opacity 0.2s;

  &:hover {
    opacity: 0.9;
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

フォームコンポーネント本体を実装します。

typescript// components/Form/Form.tsx

import React, { useState } from 'react';
import { FormProps, FormErrors, FieldDefinition, ValidationRule } from './types';
import {
  FormContainer,
  FormField,
  FormLabel,
  FormInput,
  FormTextarea,
  FormSelect,
  ErrorMessage,
  SubmitButton,
} from './styles';

export function Form<T extends Record<string, any>>({
  fields,
  initialValues = {},
  onSubmit,
  submitText = '送信',
}: FormProps<T>) {
  // フォームの値とエラーを管理
  const [values, setValues] = useState<Partial<T>>(initialValues);
  const [errors, setErrors] = useState<FormErrors<T>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
typescript// components/Form/Form.tsx(続き)

// バリデーション関数
const validateField = (
  value: any,
  validation?: ValidationRule
): string | undefined => {
  if (!validation) return undefined;

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

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

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

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

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

  return undefined;
};
typescript// components/Form/Form.tsx(続き)

// フィールド変更ハンドラ
const handleChange = (name: keyof T, value: any) => {
  setValues((prev) => ({ ...prev, [name]: value }));

  // エラーをクリア
  if (errors[name]) {
    setErrors((prev) => {
      const newErrors = { ...prev };
      delete newErrors[name];
      return newErrors;
    });
  }
};
typescript// components/Form/Form.tsx(続き)

// フォーム送信ハンドラ
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();

  // すべてのフィールドをバリデーション
  const newErrors: FormErrors<T> = {};
  fields.forEach((field) => {
    const error = validateField(
      values[field.name],
      field.validation
    );
    if (error) {
      newErrors[field.name] = error;
    }
  });

  if (Object.keys(newErrors).length > 0) {
    setErrors(newErrors);
    return;
  }

  setIsSubmitting(true);
  try {
    await onSubmit(values as T);
  } finally {
    setIsSubmitting(false);
  }
};
typescript// components/Form/Form.tsx(続き)

  // フィールドタイプに応じた入力コンポーネントをレンダリング
  const renderField = (field: FieldDefinition<T>) => {
    const value = values[field.name] ?? '';
    const hasError = !!errors[field.name];

    switch (field.type) {
      case 'textarea':
        return (
          <FormTextarea
            value={value}
            onChange={(e) => handleChange(field.name, e.target.value)}
            placeholder={field.placeholder}
            hasError={hasError}
          />
        );
typescript// components/Form/Form.tsx(続き)

      case 'select':
        return (
          <FormSelect
            value={value}
            onChange={(e) => handleChange(field.name, e.target.value)}
            hasError={hasError}
          >
            <option value="">選択してください</option>
            {field.options?.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </FormSelect>
        );
typescript// components/Form/Form.tsx(続き)

      default:
        return (
          <FormInput
            type={field.type}
            value={value}
            onChange={(e) => handleChange(field.name, e.target.value)}
            placeholder={field.placeholder}
            hasError={hasError}
          />
        );
    }
  };
typescript// components/Form/Form.tsx(続き)

  return (
    <FormContainer onSubmit={handleSubmit}>
      {fields.map((field) => (
        <FormField key={String(field.name)}>
          <FormLabel required={field.validation?.required}>
            {field.label}
          </FormLabel>
          {renderField(field)}
          {errors[field.name] && (
            <ErrorMessage>{errors[field.name]}</ErrorMessage>
          )}
        </FormField>
      ))}

      <SubmitButton type="submit" disabled={isSubmitting}>
        {isSubmitting ? '送信中...' : submitText}
      </SubmitButton>
    </FormContainer>
  );
}

これで、型安全なフォームコンポーネントが完成しました。

具体例

ユーザー管理画面の実装

実際の B2B 管理画面として、ユーザー管理機能を実装してみましょう。 まず、ユーザーデータの型定義を行います。

typescript// types/user.ts

// ユーザーの型定義
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
  department: string;
  createdAt: string;
  isActive: boolean;
}
typescript// types/user.ts(続き)

// ユーザー作成フォームの型
export interface CreateUserForm {
  name: string;
  email: string;
  password: string;
  role: 'admin' | 'user' | 'guest';
  department: string;
}

ユーザー一覧ページのコンポーネントを作成します。

typescript// pages/UserList.tsx

import React, { useState, useEffect } from 'react';
import { Table } from '../components/Table/Table';
import { Filter } from '../components/Filter/Filter';
import { Column } from '../components/Table/types';
import { FilterDefinition, FilterValues } from '../components/Filter/types';
import { User } from '../types/user';

export function UserListPage() {
  // ユーザーデータを管理
  const [users, setUsers] = useState<User[]>([]);
  const [filterValues, setFilterValues] = useState<FilterValues<User>>({});
typescript// pages/UserList.tsx(続き)

// ダミーデータの初期化(実際はAPIから取得)
useEffect(() => {
  const dummyUsers: User[] = [
    {
      id: 1,
      name: '山田太郎',
      email: 'yamada@example.com',
      role: 'admin',
      department: '開発部',
      createdAt: '2024-01-15',
      isActive: true,
    },
    {
      id: 2,
      name: '佐藤花子',
      email: 'sato@example.com',
      role: 'user',
      department: '営業部',
      createdAt: '2024-02-20',
      isActive: true,
    },
    // 他のユーザーデータ...
  ];
  setUsers(dummyUsers);
}, []);
typescript// pages/UserList.tsx(続き)

// テーブルの列定義
const columns: Column<User>[] = [
  { key: 'id', header: 'ID', width: '80px' },
  { key: 'name', header: '名前', sortable: true },
  {
    key: 'email',
    header: 'メールアドレス',
    sortable: true,
  },
  {
    key: 'role',
    header: '権限',
    render: (value) => {
      const roleLabels = {
        admin: '管理者',
        user: 'ユーザー',
        guest: 'ゲスト',
      };
      return roleLabels[value as keyof typeof roleLabels];
    },
  },
  { key: 'department', header: '部署' },
  { key: 'createdAt', header: '登録日' },
  {
    key: 'isActive',
    header: 'ステータス',
    render: (value) => (value ? '有効' : '無効'),
  },
];
typescript// pages/UserList.tsx(続き)

// フィルタ定義
const filters: FilterDefinition<User>[] = [
  {
    key: 'name',
    label: '名前',
    type: 'text',
    placeholder: '名前で検索',
  },
  {
    key: 'email',
    label: 'メールアドレス',
    type: 'text',
    placeholder: 'メールアドレスで検索',
  },
  {
    key: 'role',
    label: '権限',
    type: 'select',
    options: [
      { value: 'admin', label: '管理者' },
      { value: 'user', label: 'ユーザー' },
      { value: 'guest', label: 'ゲスト' },
    ],
  },
  {
    key: 'department',
    label: '部署',
    type: 'text',
    placeholder: '部署で検索',
  },
];
typescript// pages/UserList.tsx(続き)

// フィルタ適用ハンドラ
const handleFilterApply = () => {
  // 実際はAPIにフィルタ条件を送信してデータを取得
  console.log('フィルタ適用:', filterValues);
};

// フィルタリセットハンドラ
const handleFilterReset = () => {
  setFilterValues({});
  // 実際はフィルタなしのデータを再取得
};

// 行クリックハンドラ
const handleRowClick = (user: User) => {
  console.log('ユーザー詳細:', user);
  // 実際は詳細画面に遷移
};
typescript// pages/UserList.tsx(続き)

  return (
    <div>
      <h1>ユーザー管理</h1>

      <Filter
        filters={filters}
        values={filterValues}
        onChange={setFilterValues}
        onApply={handleFilterApply}
        onReset={handleFilterReset}
      />

      <Table
        data={users}
        columns={columns}
        onRowClick={handleRowClick}
      />
    </div>
  );
}

以下の図は、ユーザー管理画面におけるデータフローを示しています。

mermaidsequenceDiagram
  participant U as ユーザー
  participant UI as UI (UserListPage)
  participant F as Filter Component
  participant T as Table Component
  participant API as Backend API

  U->>UI: ページアクセス
  UI->>API: ユーザー一覧取得
  API-->>UI: User[]
  UI->>T: data, columns渡す
  T-->>U: テーブル表示

  U->>F: フィルタ入力
  F->>UI: onChange(filterValues)
  U->>F: 検索ボタンクリック
  F->>UI: onApply()
  UI->>API: フィルタ条件付きリクエスト
  API-->>UI: フィルタ済みUser[]
  UI->>T: 更新データ渡す
  T-->>U: フィルタ結果表示

この図からわかるように、各コンポーネントが独立して動作し、型安全な props でデータをやり取りしています。

ユーザー作成フォームの実装

次に、ユーザー作成フォームを実装します。

typescript// pages/CreateUser.tsx

import React from 'react';
import { Form } from '../components/Form/Form';
import { FieldDefinition } from '../components/Form/types';
import { CreateUserForm } from '../types/user';

export function CreateUserPage() {
  // フォームフィールドの定義
  const fields: FieldDefinition<CreateUserForm>[] = [
    {
      name: 'name',
      label: '名前',
      type: 'text',
      placeholder: '山田太郎',
      validation: {
        required: true,
        minLength: 2,
        maxLength: 50,
      },
    },
typescript// pages/CreateUser.tsx(続き)

    {
      name: 'email',
      label: 'メールアドレス',
      type: 'email',
      placeholder: 'example@company.com',
      validation: {
        required: true,
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      },
    },
typescript// pages/CreateUser.tsx(続き)

    {
      name: 'password',
      label: 'パスワード',
      type: 'password',
      validation: {
        required: true,
        minLength: 8,
        custom: (value: string) => {
          if (!/[A-Z]/.test(value)) {
            return '大文字を含めてください';
          }
          if (!/[0-9]/.test(value)) {
            return '数字を含めてください';
          }
          return undefined;
        },
      },
    },
typescript// pages/CreateUser.tsx(続き)

    {
      name: 'role',
      label: '権限',
      type: 'select',
      options: [
        { value: 'admin', label: '管理者' },
        { value: 'user', label: 'ユーザー' },
        { value: 'guest', label: 'ゲスト' },
      ],
      validation: {
        required: true,
      },
    },
typescript// pages/CreateUser.tsx(続き)

    {
      name: 'department',
      label: '部署',
      type: 'text',
      placeholder: '開発部',
      validation: {
        required: true,
      },
    },
  ];
typescript// pages/CreateUser.tsx(続き)

// フォーム送信ハンドラ
const handleSubmit = async (values: CreateUserForm) => {
  try {
    // 実際はAPIにPOSTリクエストを送信
    console.log('ユーザー作成:', values);

    // 成功メッセージを表示
    alert('ユーザーを作成しました');

    // 一覧画面に戻る
    // navigate('/users');
  } catch (error) {
    console.error('ユーザー作成エラー:', error);
    alert('ユーザーの作成に失敗しました');
  }
};
typescript// pages/CreateUser.tsx(続き)

  return (
    <div>
      <h1>ユーザー作成</h1>
      <Form
        fields={fields}
        onSubmit={handleSubmit}
        submitText="ユーザーを作成"
      />
    </div>
  );
}

カスタムフックでロジックを分離

コンポーネントからビジネスロジックを分離するために、カスタムフックを作成します。

typescript// hooks/useUserList.ts

import { useState, useEffect } from 'react';
import { User } from '../types/user';
import { FilterValues } from '../components/Filter/types';

export function useUserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  // ユーザー一覧を取得する関数
  const fetchUsers = async (
    filters?: FilterValues<User>
  ) => {
    setLoading(true);
    setError(null);

    try {
      // 実際はAPIからデータを取得
      // const response = await fetch('/api/users', {
      //   method: 'POST',
      //   body: JSON.stringify(filters),
      // });
      // const data = await response.json();

      // ダミーデータ
      const data: User[] = [
        /* ... */
      ];
      setUsers(data);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  };

  // 初回マウント時にデータを取得
  useEffect(() => {
    fetchUsers();
  }, []);

  return { users, loading, error, refetch: fetchUsers };
}

このカスタムフックを使用することで、コンポーネントはより簡潔になります。

パフォーマンス最適化のテクニック

大量のデータを扱う場合、レンダリングパフォーマンスが重要です。 以下のテクニックで最適化を行いましょう。

typescript// components/Table/OptimizedTable.tsx

import React, { useMemo } from 'react';
import { TableProps } from './types';

export function OptimizedTable<
  T extends Record<string, any>
>({ data, columns, onRowClick }: TableProps<T>) {
  // 列の定義をメモ化
  const memoizedColumns = useMemo(() => columns, [columns]);

  // データのソート処理をメモ化
  const sortedData = useMemo(() => {
    // ソート処理
    return data;
  }, [data]);

  return (
    // テーブルのレンダリング
    <div>テーブル</div>
  );
}

仮想スクロールを実装することで、数千行のデータでもスムーズに表示できます。

typescript// components/Table/VirtualizedTable.tsx

import React from 'react';
import { useVirtual } from 'react-virtual';

export function VirtualizedTable<
  T extends Record<string, any>
>({ data, columns }: TableProps<T>) {
  const parentRef = React.useRef<HTMLDivElement>(null);

  // react-virtualを使用して仮想スクロールを実装
  const rowVirtualizer = useVirtual({
    size: data.length,
    parentRef,
    estimateSize: React.useCallback(() => 48, []),
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div
        style={{
          height: `${rowVirtualizer.totalSize}px`,
          position: 'relative',
        }}
      >
        {rowVirtualizer.virtualItems.map((virtualRow) => {
          const row = data[virtualRow.index];
          return (
            <div
              key={virtualRow.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {/* 行の内容をレンダリング */}
            </div>
          );
        })}
      </div>
    </div>
  );
}

このように、仮想スクロールを実装することで、画面に表示される行だけをレンダリングし、パフォーマンスを大幅に向上させられます。

まとめ

本記事では、Emotion を活用した B2B 管理画面の構築方法を解説しました。 TypeScript の型システムと組み合わせることで、テーブル、フィルタ、フォームといった主要コンポーネントを型安全に実装できることがわかりました。

以下が本記事で解説した主要なポイントです。

実装のポイント

#項目内容
1テーマシステムThemeProvider で一貫したデザインを適用
2ジェネリック型再利用可能なコンポーネントを作成
3CSS-in-JSコンポーネントとスタイルを同じファイルで管理
4型安全性TypeScript でコンパイル時にエラーを検出
5パフォーマンスメモ化と仮想スクロールで最適化

Emotion の styled API を使用することで、コンポーネントごとに独立したスタイルを定義でき、グローバルスコープの汚染を防げます。 また、TypeScript のジェネリック型を活用することで、どのようなデータ型にも対応できる汎用的なコンポーネントを作成できました。

テーマシステムにより、デザイントークンを一元管理し、アプリケーション全体で一貫したルックアンドフィールを実現できます。 さらに、props を通じてテーマにアクセスできるため、動的なスタイリングも容易に実装可能です。

フィルタとフォームについても、型定義から自動的に UI を生成する仕組みを構築することで、開発効率を大幅に向上させられます。 バリデーションロジックも型安全に実装できるため、実行時エラーを未然に防げるでしょう。

開発効率の向上

今回紹介したアプローチにより、以下のメリットが得られます。

まず、コンポーネントの再利用性が高まるため、新しい管理画面を追加する際の開発時間が短縮されます。 型安全性により、リファクタリング時の不安が解消され、大胆なコード変更も安心して行えるのです。

また、スタイルとロジックが同じファイルにあることで、コードレビューや保守が容易になります。 IDE の補完機能も活用できるため、開発体験も大幅に向上するでしょう。

今後の拡張性

本記事で紹介した基本的なコンポーネントを拡張することで、より高度な機能を実装できます。

たとえば、ドラッグ&ドロップによる列の並び替え、複数行選択とバルク操作、エクスポート機能などを追加できるでしょう。 また、React Query や SWR といったデータフェッチングライブラリと組み合わせることで、キャッシングや楽観的更新も実現可能です。

Emotion と TypeScript の組み合わせは、これらの高度な機能を型安全に実装するための強固な基盤となります。 本記事で紹介したパターンを応用することで、さらに複雑な管理画面も効率的に構築できるはずです。

関連リンク