概要
リスト画面でデータが 0 件のとき、何も表示しないより「なぜ空なのか」「次に何をすべきか」を伝える EmptyState コンポーネントを設置するとユーザー体験が向上する。検索結果ゼロ・データ未登録・エラー後の 3 シナリオに対応した汎用コンポーネントを Tailwind CSS で実装する。
インストール
# Tailwind CSS が既にセットアップ済みであれば追加インストールは不要
npm install tailwindcss @tailwindcss/postcss
実装
汎用 EmptyState コンポーネント
// components/EmptyState.tsx
import type { ReactNode } from "react";
type Props = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
};
export default function EmptyState({ icon, title, description, action }: Props) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 text-gray-400">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800">{title}</h3>
{description && (
<p className="mb-6 max-w-sm text-sm leading-relaxed text-gray-500">{description}</p>
)}
{action && <div>{action}</div>}
</div>
);
}
シナリオ別使用例
// components/ItemList.tsx
import EmptyState from "./EmptyState";
type Item = { id: number; name: string };
type Props = {
items: Item[];
query: string;
hasError: boolean;
onAdd: () => void;
onRetry: () => void;
onClear: () => void;
};
// SVG アイコン(インライン)
function SearchIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
);
}
function InboxIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7m16 0-2 6H6l-2-6m16 0H4" />
</svg>
);
}
function AlertIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
);
}
export default function ItemList({ items, query, hasError, onAdd, onRetry, onClear }: Props) {
// エラー状態
if (hasError) {
return (
<EmptyState
icon={<AlertIcon />}
title="データの取得に失敗しました"
description="通信エラーが発生しました。しばらく待ってから再試行してください。"
action={
<button
onClick={onRetry}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white hover:bg-red-700"
>
再試行
</button>
}
/>
);
}
// 検索結果ゼロ
if (items.length === 0 && query) {
return (
<EmptyState
icon={<SearchIcon />}
title={`"${query}" に一致するアイテムはありません`}
description="キーワードを変えるか、フィルターを解除してみてください。"
action={
<button
onClick={onClear}
className="rounded-lg border border-gray-300 px-5 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
検索をクリア
</button>
}
/>
);
}
// データ未登録
if (items.length === 0) {
return (
<EmptyState
icon={<InboxIcon />}
title="まだアイテムがありません"
description="最初のアイテムを追加して始めましょう。"
action={
<button
onClick={onAdd}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
アイテムを追加
</button>
}
/>
);
}
return (
<ul className="divide-y">
{items.map((item) => (
<li key={item.id} className="px-4 py-3 text-sm text-gray-800">
{item.name}
</li>
))}
</ul>
);
}
ページへの組み込み
// app/items/page.tsx
"use client";
import { useState } from "react";
import ItemList from "@/components/ItemList";
export default function ItemsPage() {
const [items] = useState<{ id: number; name: string }[]>([]);
const [query, setQuery] = useState("");
const [hasError] = useState(false);
return (
<main className="mx-auto max-w-xl p-8">
<div className="mb-4 flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索…"
className="flex-1 rounded border px-3 py-2 text-sm"
/>
</div>
<div className="rounded-lg border">
<ItemList
items={items.filter((i) => i.name.includes(query))}
query={query}
hasError={hasError}
onAdd={() => alert("追加")}
onRetry={() => alert("再試行")}
onClear={() => setQuery("")}
/>
</div>
</main>
);
}
ポイント
icon/title/description/actionをすべて Props として受け取り、シナリオごとに差し替えられる汎用コンポーネントにする。コンポーネント内にシナリオ別分岐を書くとスコープが広がりすぎるため、呼び出し側で制御する- アイコンは
h-16 w-16 rounded-full bg-gray-100の円形コンテナに収める。エラー時はtext-red-400でアイコン色を変えて深刻度を視覚的に区別する - 検索結果ゼロのケースは「クリア」ボタン、未登録は「追加」ボタン、エラーは「再試行」ボタンと、状況に合ったアクションを提示する
max-w-smで説明文の最大幅を制限し、行が長くなりすぎないようにする。leading-relaxedで可読性を確保する- SVG アイコンはインラインで定義する代わりに
lucide-reactやheroiconsなどのライブラリを使うと管理が楽になる