4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

⚛️ フロントエンド基礎講座【第五回】React × TypeScriptの実践!useEffect・API連携・フォーム・コンポーネント分割

4
Last updated at Posted at 2026-06-29

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 オブジェクト(実際のレスポンス)
};

awaitasync 関数の中でのみ使えます。
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の実践(この記事)
  • 【第六回】まとめ・開発環境・次のステップ(近日公開)
4
5
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?