React でレイアウトコンポーネントを設計する
概要
React でアプリを作っていると、画面の多くは「ヘッダー」「本文」「カード一覧」「余白」といった似た構造の繰り返しになります。これらを毎回 div と className でベタ書きすると、画面が増えるほど余白や間隔の指定がバラバラになり、デザインの一貫性が崩れていきます。
本記事では、そうした繰り返しをレイアウトコンポーネントとして切り出し、見た目を再利用可能にする入門的な設計を扱います。React のコンポーネント分割はできるが、レイアウトの整理がうまくいかない、という段階の方を対象にしています。
コンポーネントを2種類に分けて考える
UI コンポーネントを次の2種類に分けると整理しやすくなります。
- 見た目を持つコンポーネント(Button, Card, Avatar など)… それ自体が意味を持つ部品
- 配置を担うコンポーネント(Stack, Container, Grid など)… 中身は問わず、子要素の並びと間隔だけを決める
後者が「レイアウトコンポーネント」です。自分では装飾を持たず、children をどう配置するかだけに責任を持ちます。この分離ができると、「余白を変えたい」ときに触る場所が1か所に集約されます。
よくある課題:余白が各画面に散らばる
まず、ありがちなコードです。
function ProfilePage() {
return (
<div style={{ padding: 24 }}>
<h1 style={{ marginBottom: 16 }}>プロフィール</h1>
<div style={{ marginBottom: 16 }}>
<Avatar />
</div>
<div style={{ marginBottom: 16 }}>
<p>自己紹介テキスト</p>
</div>
<button>編集する</button>
</div>
);
}
このコードは次の問題を抱えています。
marginBottom: 16が要素ごとに繰り返されている- 最後の要素にだけ margin を付け忘れる/付けすぎるミスが起きやすい
- 別の画面では間隔が
12になっていたりして揃わない - 全体の余白変更が全要素の手修正になる
間隔という配置の責務が、各ページのコンポーネントに溶け込んでいるのが原因です。
対応方針:配置の責務をコンポーネントに閉じ込める
配置専用のコンポーネントを用意し、ページ側から「どう並べるか」の記述を追い出します。入門として押さえたいのは3つです。
- Stack … 子要素を縦(または横)に一定間隔で並べる
- Container … 本文の最大幅を制限し、中央寄せする
- Grid … 2次元の格子状に並べる
実装例
Stack:一定間隔で積む
import { type ReactNode } from "react";
type StackProps = {
children: ReactNode;
gap?: number; // 要素間の間隔(px)
direction?: "row" | "column";
};
export function Stack({ children, gap = 16, direction = "column" }: StackProps) {
return (
<div style={{ display: "flex", flexDirection: direction, gap }}>
{children}
</div>
);
}
ポイントは、要素ごとの margin をやめ、親の gap で間隔を表現することです。gap は flex / grid の標準機能で、「最後の要素だけ余白がはみ出す」問題が原理的に起きません。
function ProfilePage() {
return (
<Stack gap={16}>
<h1>プロフィール</h1>
<Avatar />
<p>自己紹介テキスト</p>
<button>編集する</button>
</Stack>
);
}
各要素から余白の記述が消え、「縦に16px間隔で積む」という意図だけが残りました。
Container:本文幅を制御する
type ContainerProps = {
children: ReactNode;
maxWidth?: number; // 本文の最大幅(px)
};
export function Container({ children, maxWidth = 768 }: ContainerProps) {
return (
<div
style={{
maxWidth,
marginInline: "auto", // 左右マージンを自動で中央寄せ
paddingInline: 16, // 画面端に貼り付かないための左右余白
}}
>
{children}
</div>
);
}
横に広いディスプレイで本文が端から端まで伸びるのを防ぎ、中央寄せします。
Grid:格子状に並べる
type GridProps = {
children: ReactNode;
minItemWidth?: number; // 各要素の最小幅(px)
gap?: number;
};
export function Grid({ children, minItemWidth = 240, gap = 16 }: GridProps) {
return (
<div
style={{
display: "grid",
gap,
gridTemplateColumns: `repeat(auto-fill, minmax(${minItemWidth}px, 1fr))`,
}}
>
{children}
</div>
);
}
repeat(auto-fill, minmax(...)) を使うと、画面幅に応じて列数が自動で増減します。メディアクエリを書かずに簡易なレスポンシブが実現でき、入門段階では十分です。
組み合わせてページ骨格を作る
function DashboardPage() {
return (
<Container maxWidth={960}>
<Stack gap={24}>
<h1>ダッシュボード</h1>
<Grid minItemWidth={240} gap={16}>
<Card title="売上" />
<Card title="訪問数" />
<Card title="コンバージョン" />
</Grid>
</Stack>
</Container>
);
}
「中央寄せの960px幅の中に、24px間隔で見出しとカードグリッドを積む」という構造が、スタイルの詳細を見なくても読めるようになりました。
間隔の値は定数にまとめる
gap={16} のように数値を直接書くと、プロジェクトが育つにつれ中途半端な値が混ざり始めます。間隔はスケール(段階)として定義し、そこから選ぶようにします。
export const space = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 } as const;
type Space = keyof typeof space;
type StackProps = {
children: ReactNode;
gap?: Space;
direction?: "row" | "column";
};
export function Stack({ children, gap = "md", direction = "column" }: StackProps) {
return (
<div style={{ display: "flex", flexDirection: direction, gap: space[gap] }}>
{children}
</div>
);
}
gap に渡せる値が "xs" | "sm" | "md" | "lg" | "xl" に限定され、任意の数値を書けなくなります。デザインの一貫性を型で守れるのが利点です。
確認方法
- Stack の
gapを1か所変えて、配下の全要素の間隔が一斉に変わることを確認する - 画面幅を縮めて Grid の列数が自動で減ることを確認する
- ページ側のコンポーネントから
margin/paddingの直書きが消えているかを確認する
注意点
- レイアウトコンポーネントに色やフォントを持たせない(配置と装飾を混ぜない)
- 何でも受け取る巨大 Box を1つ作らない。用途ごとに Stack / Container / Grid と小さく分ける
- Tailwind や CSS Modules を使っているなら、
styleではなくそれらの手法で同じ考え方を適用する
まとめ
- 画面の繰り返し構造は、配置専用のレイアウトコンポーネントに切り出す
- 要素ごとの
marginをやめ、親のgapで間隔を表現すると崩れにくい - Stack / Container / Grid の3つで多くの画面骨格はカバーできる
- 間隔は数値直書きではなくスケール(トークン)にまとめ、型で選択肢を縛ると一貫性が保てる
レイアウトを部品化しておくと、画面が増えても余白の調整は1か所で済みます。まずは Stack から導入してみてください。
