実践:投稿・編集・削除ができるミニCRUDアプリに仕上げる

はじめに
この節では、これまで作ってきたスポット共有アプリを、投稿・編集・削除ができるミニCRUDアプリ に仕上げます。
CRUD という言葉は少し難しく見えますが、意味はシンプルです。
Create 作る
Read 読む
Update 更新する
Delete 削除する
今回のアプリでは、次のように考えます。
Create 新しいスポットを追加する
Read スポット一覧・詳細を見る
Update 投稿内容を編集する
Delete 投稿を削除する
ここまでできると、ただ画面を作るだけではなく、小さな実用アプリ にかなり近づきます。
この節のゴール
この節が終わると、次のことができるようになります。
- スポットを追加できる
- 投稿内容を編集できる
- 投稿を削除できる
- 追加・編集・削除した内容を一覧に反映できる
- CRUD の基本的な流れを体験できる
localStorageを使って、ブラウザ内にデータを保存できる 今日の合言葉はこれです。
作る、見る、直す、消す。これがCRUDの基本。
1. 今回作るアプリの完成イメージ
今回の完成形では、次の流れができます。
新規投稿ページでスポットを追加する
↓
一覧ページに表示される
↓
詳細ページで内容を確認する
↓
編集ページで内容を変更する
↓
一覧や詳細に反映される
↓
不要になったら削除する
↓
一覧から消える
今回は本格的なデータベースは使いません。
まずは、ブラウザの localStorage に保存します。
2. localStorage とは
localStorage は、ブラウザの中にデータを保存する仕組みです。
たとえば、投稿したスポットを保存しておけば、ページを再読み込みしても残せます。
投稿する
↓
localStorage に保存する
↓
一覧で読み込む
↓
画面に表示する
ただし、これは学習用の保存方法です。
本格的なアプリでは、Supabase や Firebase などのデータベースを使います。
3. 今回のファイル構成
必要最低限で、次のファイルを作ります。
app
└─ spots
├─ page.tsx
├─ new
│ └─ page.tsx
└─ [id]
├─ page.tsx
└─ edit
└─ page.tsx
lib
├─ spot-types.ts
└─ spot-storage.ts
役割はこうです。
| ファイル | 役割 |
|---|---|
app/spots/page.tsx | 一覧ページ |
app/spots/new/page.tsx | 新規投稿ページ |
app/spots/[id]/page.tsx | 詳細ページ・削除 |
app/spots/[id]/edit/page.tsx | 編集ページ |
lib/spot-types.ts | 型をまとめる |
lib/spot-storage.ts | 保存・取得・編集・削除をまとめる |
4. 型を作る
まず、スポットの形を決めます。
lib/spot-types.ts
export const SPOT_CATEGORIES = [
'図書館',
'カフェ',
'学食',
'作業スペース',
'休憩スポット',
] as const;
export type SpotCategory = (typeof SPOT_CATEGORIES)[number];
export type Spot = {
id: string;
name: string;
description: string;
category: string;
location: string;
imageUrl: string;
createdAt: string;
updatedAt: string;
};
export type SpotFormInput = {
name: string;
description: string;
category: string;
location: string;
imageUrl: string;
};
ここでは、Spot という型を作っています。
id どのスポットか見分ける番号
name スポット名
description 説明文
category カテゴリ
location 場所
imageUrl 画像URL
createdAt 作成日時
updatedAt 更新日時
5. 保存処理を作る
次に、localStorage に保存する処理を作ります。
lib/spot-storage.ts
import type { Spot, SpotFormInput } from '@/lib/spot-types';
const SPOTS_STORAGE_KEY = 'spot-share-spots';
const DEFAULT_SPOTS: Spot[] = [
{
id: 'spot-1',
name: '静かな図書館',
description:
'静かに勉強したいときに使いやすい図書館です。席数も多く、集中しやすい雰囲気があります。',
category: '図書館',
location: '名古屋駅 徒歩5分',
imageUrl:
'https://images.unsplash.com/photo-1521587760476-6c12a4b040da?auto=format&fit=crop&w=1200&q=80',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'spot-2',
name: 'おしゃれカフェ',
description:
'友人と話したり、少し作業したりするのに使いやすいカフェです。落ち着いた雰囲気があります。',
category: 'カフェ',
location: '栄駅 徒歩3分',
imageUrl:
'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?auto=format&fit=crop&w=1200&q=80',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
/**
* 役割: ブラウザ環境かどうかを判定する
* 入力: なし
* 出力: ブラウザ環境なら true、それ以外なら false
*/
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
/**
* 役割: localStorage からスポット一覧を取得する
* 入力: なし
* 出力: スポット一覧
*/
export function getAllSpots(): Spot[] {
if (!isBrowser()) {
return DEFAULT_SPOTS;
}
const storedValue = window.localStorage.getItem(SPOTS_STORAGE_KEY);
if (storedValue === null) {
window.localStorage.setItem(SPOTS_STORAGE_KEY, JSON.stringify(DEFAULT_SPOTS));
return DEFAULT_SPOTS;
}
try {
const parsedValue = JSON.parse(storedValue) as Spot[];
if (!Array.isArray(parsedValue)) {
return DEFAULT_SPOTS;
}
return parsedValue;
} catch {
return DEFAULT_SPOTS;
}
}
/**
* 役割: スポット一覧を localStorage に保存する
* 入力: spots - 保存したいスポット一覧
* 出力: なし
*/
function saveAllSpots(spots: Spot[]): void {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(SPOTS_STORAGE_KEY, JSON.stringify(spots));
}
/**
* 役割: 指定IDのスポットを取得する
* 入力: spotId - 探したいスポットID
* 出力: 見つかれば Spot、なければ null
*/
export function getSpotById(spotId: string): Spot | null {
const spots = getAllSpots();
const foundSpot = spots.find((spot) => {
return spot.id === spotId;
});
return foundSpot ?? null;
}
/**
* 役割: 新しいスポットを作成して保存する
* 入力: values - フォーム入力値
* 出力: 作成したスポットのID
*/
export function createSpot(values: SpotFormInput): string {
const spots = getAllSpots();
const now = new Date().toISOString();
const nextId = `spot-${crypto.randomUUID()}`;
const newSpot: Spot = {
id: nextId,
name: values.name,
description: values.description,
category: values.category,
location: values.location,
imageUrl: values.imageUrl,
createdAt: now,
updatedAt: now,
};
saveAllSpots([newSpot, ...spots]);
return nextId;
}
/**
* 役割: 既存スポットを更新する
* 入力: spotId - 更新対象ID, values - 更新内容
* 出力: 更新できたら true、できなければ false
*/
export function updateSpot(spotId: string, values: SpotFormInput): boolean {
const spots = getAllSpots();
const nextSpots = spots.map((spot) => {
if (spot.id !== spotId) {
return spot;
}
return {
...spot,
...values,
updatedAt: new Date().toISOString(),
};
});
saveAllSpots(nextSpots);
return spots.some((spot) => {
return spot.id === spotId;
});
}
/**
* 役割: 指定IDのスポットを削除する
* 入力: spotId - 削除対象ID
* 出力: 削除できたら true、できなければ false
*/
export function deleteSpot(spotId: string): boolean {
const spots = getAllSpots();
const nextSpots = spots.filter((spot) => {
return spot.id !== spotId;
});
if (nextSpots.length === spots.length) {
return false;
}
saveAllSpots(nextSpots);
return true;
}
6. 一覧ページを作る
保存されたスポットを一覧で表示します。
app/spots/page.tsx
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { getAllSpots } from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';
/**
* 役割: 保存済みスポットの一覧を表示する
* 入力: なし
* 出力: スポット一覧画面
*/
export default function SpotsPage(): JSX.Element {
const [spots] = useState<Spot[]>(() => getAllSpots());
return (
<main style={{ maxWidth: '800px', margin: '0 auto', padding: '32px' }}>
<h1>スポット一覧</h1>
<p>投稿されたスポットがここに表示されます。</p>
<div style={{ marginTop: '24px' }}>
<Link href="/spots/new">+ 新しいスポットを投稿する</Link>
</div>
<section
style={{
display: 'grid',
gap: '16px',
marginTop: '24px',
}}
>
{spots.map((spot) => {
return (
<article
key={spot.id}
style={{
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: '16px',
border: '1px solid #ddd',
borderRadius: '12px',
padding: '16px',
}}
>
{spot.imageUrl.length > 0 ? (
<img
src={spot.imageUrl}
alt={spot.name}
style={{
width: '120px',
height: '90px',
objectFit: 'cover',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '120px',
height: '90px',
display: 'grid',
placeItems: 'center',
borderRadius: '8px',
background: '#f5f8fa',
color: '#666',
}}
>
画像なし
</div>
)}
<div>
<h2>{spot.name}</h2>
<p>{spot.category}・{spot.location}</p>
<p>{spot.description}</p>
<Link href={`/spots/${spot.id}`}>詳細を見る</Link>
</div>
</article>
);
})}
</section>
</main>
);
}
7. 新規投稿ページを作る
フォームからスポットを追加します。
app/spots/new/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createSpot } from '@/lib/spot-storage';
import { SPOT_CATEGORIES } from '@/lib/spot-types';
import type { SpotFormInput } from '@/lib/spot-types';
/**
* 役割: 新しいスポットを追加するページを表示する
* 入力: なし
* 出力: 新規投稿フォーム画面
*/
export default function NewSpotPage(): JSX.Element {
const router = useRouter();
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [category, setCategory] = useState<string>(SPOT_CATEGORIES[0]);
const [location, setLocation] = useState<string>('');
const [imageUrl, setImageUrl] = useState<string>('');
/**
* 役割: フォーム入力値をまとめる
* 入力: なし
* 出力: SpotFormInput
*/
const buildFormInput = (): SpotFormInput => {
return {
name: name.trim(),
description: description.trim(),
category,
location: location.trim(),
imageUrl: imageUrl.trim(),
};
};
/**
* 役割: 新しいスポットを保存して詳細ページへ移動する
* 入力: event - フォーム送信イベント
* 出力: なし
*/
const handleSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const createdSpotId = createSpot(buildFormInput());
router.push(`/spots/${createdSpotId}`);
};
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '32px' }}>
<h1>新しいスポットを投稿する</h1>
<p>入力した内容を保存して、一覧に追加します。</p>
<form
onSubmit={handleSubmit}
style={{
display: 'grid',
gap: '16px',
marginTop: '24px',
}}
>
<label style={{ display: 'grid', gap: '8px' }}>
<span>スポット名</span>
<input
type="text"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
placeholder="例)静かな図書館"
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>説明文</span>
<textarea
value={description}
onChange={(event) => {
setDescription(event.target.value);
}}
rows={5}
placeholder="例)集中して勉強しやすい場所です。"
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>カテゴリ</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value);
}}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
>
{SPOT_CATEGORIES.map((spotCategory) => {
return (
<option key={spotCategory} value={spotCategory}>
{spotCategory}
</option>
);
})}
</select>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>場所</span>
<input
type="text"
value={location}
onChange={(event) => {
setLocation(event.target.value);
}}
placeholder="例)名古屋駅 徒歩5分"
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>画像URL</span>
<input
type="url"
value={imageUrl}
onChange={(event) => {
setImageUrl(event.target.value);
}}
placeholder="例)https://example.com/image.jpg"
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<button
type="submit"
style={{
padding: '12px 16px',
border: 'none',
borderRadius: '8px',
background: '#08131a',
color: '#fff',
fontWeight: 700,
cursor: 'pointer',
}}
>
保存する
</button>
</form>
</main>
);
}
8. 詳細ページを作る
詳細ページでは、1件のスポットを表示します。
ここに、編集ボタンと削除ボタンも置きます。
app/spots/[id]/page.tsx
'use client';
import Link from 'next/link';
import { notFound, useRouter } from 'next/navigation';
import { use, useMemo, useState } from 'react';
import { deleteSpot, getSpotById } from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';
type SpotDetailPageProps = {
params: Promise<{
id: string;
}>;
};
/**
* 役割: スポット詳細ページを表示し、削除処理を行う
* 入力: params - URLに含まれるスポットID
* 出力: スポット詳細画面
*/
export default function SpotDetailPage({
params,
}: SpotDetailPageProps): JSX.Element {
const router = useRouter();
const { id } = use(params);
const [spot] = useState<Spot | null>(() => getSpotById(id));
const formattedDate = useMemo(() => {
if (spot === null) {
return '';
}
return new Date(spot.updatedAt).toLocaleString('ja-JP');
}, [spot]);
/**
* 役割: 表示中のスポットを削除して一覧へ戻る
* 入力: なし
* 出力: なし
*/
const handleDelete = (): void => {
const shouldDelete = window.confirm('このスポットを削除しますか?');
if (!shouldDelete) {
return;
}
deleteSpot(id);
router.push('/spots');
};
if (spot === null) {
notFound();
}
return (
<main style={{ maxWidth: '720px', margin: '0 auto', padding: '32px' }}>
<Link href="/spots">← 一覧へ戻る</Link>
<article style={{ marginTop: '24px' }}>
{spot.imageUrl.length > 0 ? (
<img
src={spot.imageUrl}
alt={spot.name}
style={{
width: '100%',
height: '260px',
objectFit: 'cover',
borderRadius: '12px',
}}
/>
) : null}
<p
style={{
display: 'inline-block',
marginTop: '16px',
padding: '4px 10px',
borderRadius: '999px',
background: '#e6f6f2',
color: '#1e7b65',
fontWeight: 700,
}}
>
{spot.category}
</p>
<h1>{spot.name}</h1>
<p>場所: {spot.location}</p>
<p>更新日: {formattedDate}</p>
<p style={{ lineHeight: 1.8 }}>{spot.description}</p>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<Link href={`/spots/${spot.id}/edit`}>編集する</Link>
<button
type="button"
onClick={handleDelete}
style={{
border: 'none',
background: '#fdf3f3',
color: '#b22323',
cursor: 'pointer',
fontWeight: 700,
}}
>
削除する
</button>
</div>
</article>
</main>
);
}
9. 編集ページを作る
編集ページでは、既存の内容を入力欄に入れた状態で表示します。
保存すると、既存データを更新します。
app/spots/[id]/edit/page.tsx
'use client';
import { notFound, useRouter } from 'next/navigation';
import { use, useState } from 'react';
import { getSpotById, updateSpot } from '@/lib/spot-storage';
import { SPOT_CATEGORIES } from '@/lib/spot-types';
import type { Spot, SpotFormInput } from '@/lib/spot-types';
type EditSpotPageProps = {
params: Promise<{
id: string;
}>;
};
/**
* 役割: スポット編集ページを表示する
* 入力: params - URLに含まれるスポットID
* 出力: 編集フォーム画面
*/
export default function EditSpotPage({
params,
}: EditSpotPageProps): JSX.Element {
const router = useRouter();
const { id } = use(params);
const [spot] = useState<Spot | null>(() => getSpotById(id));
if (spot === null) {
notFound();
}
return <EditSpotForm spot={spot} onSaved={() => router.push(`/spots/${id}`)} />;
}
type EditSpotFormProps = {
spot: Spot;
onSaved: () => void;
};
/**
* 役割: 既存スポットの編集フォームを表示し、保存する
* 入力: spot - 編集対象, onSaved - 保存後に実行する関数
* 出力: 編集フォームUI
*/
function EditSpotForm({ spot, onSaved }: EditSpotFormProps): JSX.Element {
const [name, setName] = useState<string>(spot.name);
const [description, setDescription] = useState<string>(spot.description);
const [category, setCategory] = useState<string>(spot.category);
const [location, setLocation] = useState<string>(spot.location);
const [imageUrl, setImageUrl] = useState<string>(spot.imageUrl);
/**
* 役割: フォーム入力値をまとめる
* 入力: なし
* 出力: SpotFormInput
*/
const buildFormInput = (): SpotFormInput => {
return {
name: name.trim(),
description: description.trim(),
category,
location: location.trim(),
imageUrl: imageUrl.trim(),
};
};
/**
* 役割: 編集内容を保存する
* 入力: event - フォーム送信イベント
* 出力: なし
*/
const handleSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
event.preventDefault();
updateSpot(spot.id, buildFormInput());
onSaved();
};
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '32px' }}>
<h1>スポットを編集する</h1>
<p>内容を変更して保存すると、一覧や詳細に反映されます。</p>
<form
onSubmit={handleSubmit}
style={{
display: 'grid',
gap: '16px',
marginTop: '24px',
}}
>
<label style={{ display: 'grid', gap: '8px' }}>
<span>スポット名</span>
<input
type="text"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>説明文</span>
<textarea
value={description}
onChange={(event) => {
setDescription(event.target.value);
}}
rows={5}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>カテゴリ</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value);
}}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
>
{SPOT_CATEGORIES.map((spotCategory) => {
return (
<option key={spotCategory} value={spotCategory}>
{spotCategory}
</option>
);
})}
</select>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>場所</span>
<input
type="text"
value={location}
onChange={(event) => {
setLocation(event.target.value);
}}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>画像URL</span>
<input
type="url"
value={imageUrl}
onChange={(event) => {
setImageUrl(event.target.value);
}}
style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
/>
</label>
<button
type="submit"
style={{
padding: '12px 16px',
border: 'none',
borderRadius: '8px',
background: '#08131a',
color: '#fff',
fontWeight: 700,
cursor: 'pointer',
}}
>
保存する
</button>
</form>
</main>
);
}
10. 動かして確認する
開発サーバーを起動します。
npm run dev
ブラウザで開きます。
http://localhost:3000/spots
次の順番で試してください。
1. 一覧ページを見る
2. 新しいスポットを投稿する
3. 詳細ページで確認する
4. 編集する
5. 一覧へ戻って内容が変わったか確認する
6. 詳細ページから削除する
7. 一覧から消えたか確認する
これができれば、CRUD の基本は完成です。
11. CRUD の流れをもう一度整理する
今回のコードでは、次のように対応しています。
| 操作 | 関数 | 画面 |
|---|---|---|
| 作る | createSpot | 新規投稿ページ |
| 読む | getAllSpots, getSpotById | 一覧・詳細 |
| 更新する | updateSpot | 編集ページ |
| 削除する | deleteSpot | 詳細ページ |
つまり、画面と関数がセットになっています。
12. よくあるつまずき
1. 投稿しても一覧に出ない
createSpot の中で保存できているか確認してください。
saveAllSpots([newSpot, ...spots]);
また、一覧ページを開き直して確認してください。
2. 編集しても変わらない
updateSpot が呼ばれているか確認します。
updateSpot(spot.id, buildFormInput());
保存後に詳細ページへ戻っているかも確認してください。
3. 削除しても残る
deleteSpot の中で filter できているか確認します。
const nextSpots = spots.filter((spot) => {
return spot.id !== spotId;
});
4. 詳細ページが表示されない
URLのIDと、保存されているスポットのIDが合っているか確認してください。
/spots/spot-1
/spots/spot-xxxxxxxx
今回の新規投稿では、crypto.randomUUID() を使ってIDを作っています。
13. ここまでのまとめ
この節では、ミニCRUDアプリを作りました。
できるようになったことは、次の5つです。
- スポットを追加する
- スポットを一覧に表示する
- スポット詳細を見る
- 投稿内容を編集する
- 投稿を削除する
これで、Webアプリの基本の流れをかなり体験できました。
CRUD は、ほとんどのアプリで使われます。
メモアプリ
ブログ
商品管理
予約管理
投稿サービス
タスク管理
どれも、基本は「作る・見る・直す・消す」です。
練習問題
問題1
CRUD の C は何を意味しますか。
問題2
スポット一覧を取得する関数は何ですか。
問題3
新しいスポットを追加する関数は何ですか。
問題4
削除するときに使った配列メソッドは何ですか。
回答
問題1の回答
Create
新しく作る、という意味です。
問題2の回答
getAllSpots
問題3の回答
createSpot
問題4の回答
filter
次に進む前のチェック
次へ進む前に、次の5つを確認してください。
- 新しいスポットを追加できる
- 追加したスポットが一覧に出る
- 詳細ページを開ける
- 編集して保存できる
- 削除すると一覧から消える ここまでできればOKです。
次は、お気に入り機能を追加して、よりアプリらしい操作を作っていきます。
