1. 👋 はじめに
前回はReactの基礎(コンポーネント・Props・State・useState)を学びました。
今回はより実践的な内容です!
今回学ぶこと:
useEffectでデータを取得する- フォームのバリデーションを型安全に実装する
- コンポーネントを適切に分割する設計を学ぶ
この記事を読めば:
- ✅
useEffectの使い方がわかる - ✅ APIからデータを取得して表示できる
- ✅ 型安全なフォームが実装できる
- ✅ コンポーネント分割の考え方がわかる
2. 🔁 useEffect:副作用を扱うフック
useEffect とは?
useEffectとは「コンポーネントのレンダリング後に実行する処理」を登録するフックです。副作用(Side Effect) とは、コンポーネントの描画以外の処理のことです。
- APIからデータを取得する
- タイマーをセットする
- ブラウザのタイトルを変更する
useEffect の基本構文
💡 マウントとは?
コンポーネントが「初めて画面に表示される」ことを マウント と呼びます。
反対に画面から消えることを アンマウント と呼びます。ページを開く → コンポーネントがマウント(表示)される ↓ useEffect が実行される ↓ 別のページに移動 → コンポーネントがアンマウント(消える)される ↓ クリーンアップ関数が実行される
import { useEffect } from "react";
useEffect(() => {
// ↓ コンポーネントがマウント(表示)された後に実行される処理
console.log("レンダリング完了!");
// ↓ クリーンアップ関数:コンポーネントがアンマウント(消える)時に実行
// タイマーの解除・通信のキャンセルなど「後片付け」に使う
return () => {
console.log("コンポーネントが消えました");
};
}, [依存配列]); // ← いつ実行するかを制御する
依存配列による実行タイミングの違い
| 依存配列 | 実行タイミング |
|---|---|
| なし | レンダリングのたびに毎回実行 |
[](空) |
最初の1回だけ実行(マウント時) |
[value] |
value が変わるたびに実行 |
import { useState, useEffect } from "react";
const Example = () => {
const [count, setCount] = useState(0);
const [userId, setUserId] = useState(1);
// ① 毎回実行(依存配列なし)
useEffect(() => {
console.log("毎回実行される");
});
// ② 最初の1回だけ実行
useEffect(() => {
console.log("マウント時に1回だけ実行");
}, []);
// ③ userId が変わるたびに実行
useEffect(() => {
console.log(`userId が ${userId} に変わった`);
}, [userId]);
return (
<div>
<button onClick={() => setCount(count + 1)}>
カウント:{count}
</button>
<button onClick={() => setUserId(userId + 1)}>
ユーザーID:{userId}
</button>
</div>
);
};
クリーンアップ関数の実例
クリーンアップ関数とは「コンポーネントが画面から消えるときに実行される後片付け処理」です。
タイマーの解除・通信のキャンセルなどに使います。
useEffect(() => {
// タイマーをセット
const timer = setInterval(() => {
console.log("1秒ごとに実行");
}, 1000);
// ✅ クリーンアップ:コンポーネントが消えるときにタイマーを解除
return () => {
clearInterval(timer); // これがないとコンポーネントが消えてもタイマーが動き続ける!
};
}, []);
💡 React 18のStrict Modeに注意!
開発環境ではuseEffectが意図的に2回実行されます。
これはReact 18のStrict Modeという機能で、
「クリーンアップ関数を正しく書いているか」を確認するためです。
本番環境では1回しか実行されないので安心してください。
3. 🌐 APIからデータを取得する
fetch + useEffect + TypeScript の組み合わせ
💡
async関数とは?
非同期処理(時間がかかる処理)を順番に待ちながら実行するための関数です。const normal = () => { const data = fetch("..."); // data は Promise { <pending> } というオブジェクトになる // Promiseをawaitで「unwrap」しないとデータの中身を取り出せない console.log(data); // → Promise { <pending> } }; const asyncFunc = async () => { const data = await fetch("..."); // awaitでPromiseをunwrapして中のデータを取り出す console.log(data); // → Response オブジェクト(実際のレスポンス) };
awaitはasync関数の中でのみ使えます。
APIの取得・ファイルの読み書きなど「時間がかかる処理」に使います。
import { useState, useEffect } from "react";
// APIのレスポンスの型を定義
type Post = {
id: number;
title: string;
body: string;
userId: number;
};
const PostList = () => {
const [posts, setPosts] = useState<Post[]>([]); // 取得したデータを保存するState
const [isLoading, setIsLoading] = useState(true); // 読み込み中かどうかのState
const [error, setError] = useState<string | null>(null); // エラーメッセージのState
useEffect(() => {
// ↓ useEffect の中に async 関数を定義する(後ほど詳しく説明)
const fetchPosts = async (): Promise<void> => {
try {
setIsLoading(true); // 読み込み開始
setError(null); // エラーをリセット
// ↓ await でデータが届くまで待つ
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5"
);
// ↓ response.ok が false = 通信は成功したがサーバーがエラーを返した
if (!response.ok) {
throw new Error(`取得に失敗しました(${response.status})`);
}
// ↓ レスポンスを Post[] 型の JSON として解析する
const data: Post[] = await response.json();
setPosts(data); // StateにデータをセットするとReactが自動で再レンダリング
} catch (e) {
// ↓ fetch失敗・サーバーエラーなどすべてのエラーをここでキャッチ
setError(e instanceof Error ? e.message : "予期しないエラーが発生しました");
} finally {
// ↓ 成功・失敗どちらの場合も必ず実行(ローディングを終了する)
setIsLoading(false);
}
};
fetchPosts(); // ← 定義した async 関数を呼び出す
}, []); // [] = マウント時に1回だけ実行(ページが表示されたらデータを取得)
// ローディング中
if (isLoading) {
return <p>読み込み中...</p>;
}
// エラー時
if (error) {
return <p>エラー:{error}</p>;
}
// データ表示
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
};
なぜ useEffect 内に async 関数を定義するの?
// ❌ これはできない:useEffect に直接 async を付けられない
useEffect(async () => {
const data = await fetch("...");
}, []);
// ✅ 内側に async 関数を定義して呼び出す
useEffect(() => {
const fetchData = async () => {
const data = await fetch("...");
};
fetchData(); // ← 定義した関数を呼び出す
}, []);
理由:
useEffectはクリーンアップ関数(またはundefined)を返す必要がありますが、
async関数は必ずPromiseを返すため、そのまま使えません。
4. 📝 型安全なフォームを作る
フォームの状態を型で管理する
import { useState } from "react";
// フォームの入力値の型
type ContactForm = {
name: string;
email: string;
message: string;
};
// バリデーションエラーの型
type FormErrors = Partial<Record<keyof ContactForm, string>>;
// Partial = すべてのプロパティをオプショナルにする
// Record<keyof ContactForm, string> = ContactFormのキーをstringの値に対応させる
// つまり { name?: string; email?: string; message?: string }
const ContactFormPage = () => {
const [form, setForm] = useState<ContactForm>({
name: "",
email: "",
message: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitted, setIsSubmitted] = useState(false);
// 入力値を更新する(すべての入力欄を1つの関数で管理)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
): void => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
// エラーをリセット
setErrors((prev) => ({ ...prev, [name]: undefined }));
};
// ⚠️ 重要:HTMLの name 属性は ContactForm のキーと完全に一致させること!
// <input name="name" /> → form.name を更新
// <input name="email" /> → form.email を更新
// <input name="massage" /> → ❌ タイポ!form に反映されずに静かにバグる
// バリデーション
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!form.name.trim()) {
newErrors.name = "名前を入力してください";
}
if (!form.email.trim()) {
newErrors.email = "メールアドレスを入力してください";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
newErrors.email = "正しいメールアドレスを入力してください";
}
if (!form.message.trim()) {
newErrors.message = "メッセージを入力してください";
} else if (form.message.length < 10) {
newErrors.message = "メッセージは10文字以上入力してください";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0; // エラーがなければtrue
};
// 送信処理
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault(); // デフォルトの送信をキャンセル
if (!validate()) return; // バリデーション失敗なら中断
// 送信処理(実際はAPIに送る)
console.log("送信データ:", form);
setIsSubmitted(true);
};
// 送信完了画面
if (isSubmitted) {
return (
<div>
<h2>✅ 送信完了!</h2>
<p>{form.name} さん、お問い合わせありがとうございます。</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">名前</label>
<input
id="name"
name="name"
type="text"
value={form.name}
onChange={handleChange}
placeholder="山田 太郎"
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="taro@example.com"
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="message">メッセージ</label>
<textarea
id="message"
name="message"
value={form.message}
onChange={handleChange}
placeholder="お問い合わせ内容を入力..."
rows={4}
/>
{errors.message && <span className="error">{errors.message}</span>}
</div>
<button type="submit">送信する</button>
</form>
);
};
5. 🧩 コンポーネントの分割設計
なぜ分割するのか?
コンポーネントが大きくなりすぎると:
- 読みにくい・修正しにくい
- 同じUIを別の場所で使いまわせない
- テストが書きにくい
分割の目安
| 状況 | 判断 |
|---|---|
| 同じUIが複数の場所に出てくる | 分割する |
| コンポーネントが100行を超えてきた | 分割を検討 |
| 「このコンポーネントは何をするか」が説明しにくい | 分割する |
| 別のページでも使いたい | 分割する |
分割の実践例
大きな1つのコンポーネントを分割してみましょう。
// ❌ 分割前:すべてが1つのコンポーネントに詰まっている
const UserPage = () => {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
// ユーザー情報のUI
// 投稿リストのUI
// ページネーションのUI
// ...100行以上のJSX
return (
<div>
{/* ユーザー情報 */}
<div className="user-card">
<img src={user?.avatar} alt={user?.name} />
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
{/* 投稿リスト */}
<ul>
{posts.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
</div>
);
};
// ✅ 分割後:役割ごとに小さなコンポーネントに分ける
// ※ 型定義は src/types/ にまとめておき、各ファイルでimportして使う
import { User } from "../types/user";
import { Post } from "../types/post";
// ① ユーザー情報カード
type UserCardProps = {
user: User;
};
const UserCard = ({ user }: UserCardProps) => {
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
// ② 投稿アイテム
type PostItemProps = {
post: Post;
};
const PostItem = ({ post }: PostItemProps) => {
return (
<li>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
);
};
// ③ 投稿リスト
type PostListProps = {
posts: Post[];
};
const PostList = ({ posts }: PostListProps) => {
return (
<ul>
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</ul>
);
};
// ④ ページ全体(小さなコンポーネントを組み合わせるだけ)
const UserPage = () => {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
// データ取得のuseEffect...
if (!user) return <p>読み込み中...</p>;
return (
<div>
<UserCard user={user} />
<PostList posts={posts} />
</div>
);
};
ファイル構造の整理
src/
├── components/ ← 再利用できる共通コンポーネント
│ ├── UserCard.tsx
│ ├── PostItem.tsx
│ └── PostList.tsx
├── pages/ ← ページ単位のコンポーネント
│ └── UserPage.tsx
├── types/ ← 型定義をまとめる
│ ├── user.ts
│ └── post.ts
├── hooks/ ← カスタムフックをまとめる(応用)
│ └── useUser.ts
└── App.tsx
💡 型定義は別ファイルにまとめると便利!
// src/types/user.ts export type User = { id: number; name: string; email: string; avatar: string; }; // src/types/post.ts export type Post = { id: number; title: string; body: string; userId: number; };複数のコンポーネントで同じ型を使いまわせるようになります。
6. 🎯 まとめ
| 概念 | 一言で言うと |
|---|---|
| 🔁 useEffect | レンダリング後に実行する処理を登録するフック |
[] 依存配列 |
最初の1回だけ実行(API取得の定番パターン) |
| 🌐 API連携 |
fetch + async/await + 型定義の組み合わせ |
| 📝 フォーム |
type で入力値・エラーを型管理・バリデーション関数を分離 |
| 🧩 コンポーネント分割 | 役割ごとに小さく分けて再利用しやすくする |
Reactで大切な考え方:
「データの流れ」を意識する。
State → Props → 画面の一方向の流れを保つことで、
バグが少なく・読みやすいコードになります💪
次回はシリーズ最終回!開発環境・Next.js・ベストプラクティス・学習ロードマップ を解説します!
Reactをさらに発展させる技術スタックと、これからの学び方をまとめます🌟
💬 質問や感想があれば、コメント欄でお気軽にどうぞ!
👍 役に立ったら、いいね&ストックをお願いします!
🎓 ここまで読んでくださって、本当にありがとうございました!
🔗 シリーズ記事
- 【第一回】Webページの仕組み・HTML基礎
- 【第二回】CSSで見た目を整える
- 【第三回】TypeScriptでインタラクションを作る
- 【第四回】React × TypeScriptの基礎
- 【第五回】React × TypeScriptの実践(この記事)
- 【第六回】まとめ・開発環境・次のステップ(近日公開)