React + Vite入門|コンポーネントベースのUI開発を始める方法


はじめに

前回の記事ではTypeScriptの基本的な型注釈・インターフェース・ジェネリクスを解説して、これまでのJavaScriptコードに型安全性を持たせる方法を紹介しました。今回はその発展としてReactとViteを組み合わせてコンポーネントベースのUI開発を始める方法を解説します。Reactはこの連載で学んできたHTML・CSS・JavaScript・TypeScriptの知識をそのまま活かせるUIライブラリです。「難しそう」というイメージを持っている方も、基本的な考え方は今まで学んできたことの延長線上にあります。


1. Reactとは何か

1.1 ReactはUIを作るためのライブラリ

ReactはMeta(旧Facebook)が開発したJavaScriptのUIライブラリです。フレームワークではなく「ライブラリ」であるため、ルーティングや状態管理は別途ライブラリを追加することが多いです。

比較内容
ReactUIを構築するためのコアライブラリ
Vue.jsテンプレート構文が中心のフレームワーク
Svelteコンパイル時にVanilla JSに変換するフレームワーク
Next.jsReactベースのフルスタックWebフレームワーク

1.2 コンポーネントという考え方

Reactの中心概念はコンポーネントです。UIを「再利用可能な部品」に分割して組み合わせます。

従来のHTMLの考え方:
index.html → 1ファイルにすべてのHTMLが書かれている

Reactのコンポーネントの考え方:
App
├── Header(ヘッダーコンポーネント)
│   ├── Logo
│   └── Navigation
├── Main
│   ├── HeroSection
│   ├── PostList
│   │   ├── PostCard(繰り返し使われる)
│   │   ├── PostCard
│   │   └── PostCard
│   └── Sidebar
└── Footer

2. プロジェクトのセットアップ

2.1 React + Viteプロジェクトを作成する

bash

# Viteでプロジェクトを作成する
npm create vite@latest my-react-app

# テンプレートを選択するプロンプトが表示される
# → フレームワーク:React
# → バリアント:TypeScript(今回はTSを使う)

cd my-react-app
npm install
npm run dev

2.2 作成されたフォルダ構成

my-react-app/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── public/
│   └── vite.svg
└── src/
    ├── main.tsx          ← エントリーポイント
    ├── App.tsx           ← ルートコンポーネント
    ├── App.css
    ├── index.css
    └── assets/
        └── react.svg

2.3 main.tsxを確認する

tsx

// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';

createRoot( document.getElementById( 'root' )! )
    .render(
        <StrictMode>
            <App />
        </StrictMode>
    );

💡 .tsx という拡張子はTypeScript + JSXを使うファイルに使います: JSXを含むファイルは .tsx(TypeScript)または .jsx(JavaScript)とします。JSXを含まないファイルは .ts.js を使います。


3. JSXの書き方

3.1 JSXとは

JSXはJavaScriptの中にHTMLのような記法を書ける構文拡張です。BabelやViteがJavaScriptに変換します。

// JSX(見た目はHTMLに似ているが実際はJavaScript)
const element = (
    <div className="card">
        <h2>タイトル</h2>
        <p>本文テキスト</p>
    </div>
);

// 上記は内部的にこう変換される
const element = React.createElement(
    'div',
    { className: 'card' },
    React.createElement( 'h2', null, 'タイトル' ),
    React.createElement( 'p',  null, '本文テキスト' )
);

3.2 HTMLとJSXの違い

// 主な違い
// ① classはclassNameと書く
<div className="container">...</div>

// ② for属性はhtmlForと書く
<label htmlFor="email">メールアドレス</label>

// ③ 自己終了タグは必ず閉じる
<input type="text" />
<br />
<img src="photo.jpg" alt="写真" />

// ④ JavaScriptの式は{}で埋め込む
const name = '山田太郎';
<p>こんにちは、{name}さん</p>

// ⑤ コメントは{/* */}で書く
{/* これはコメント */}

// ⑥ スタイルはオブジェクトで書く
<div style={{ color: 'red', fontSize: '16px' }}>テキスト</div>

// ⑦ 隣接する要素は1つのルート要素か<>で囲む必要がある
return (
    <>
        <h1>タイトル</h1>
        <p>本文</p>
    </>
);

4. 関数コンポーネントを作る

4.1 最もシンプルなコンポーネント

// src/components/Greeting.tsx

// 関数コンポーネント(現在の主流)
function Greeting() {
    return (
        <div className="greeting">
            <h1>こんにちは!</h1>
            <p>Reactへようこそ。</p>
        </div>
    );
}

export default Greeting;

4.2 propsで外から値を受け取る

// src/components/PostCard.tsx

// propsの型定義
interface PostCardProps {
    title:     string;
    excerpt:   string;
    date:      string;
    category?: string;   // オプショナル
    imageUrl?: string;
}

// propsを受け取るコンポーネント
function PostCard( { title, excerpt, date, category, imageUrl }: PostCardProps ) {
    return (
        <article className="post-card">
            {imageUrl && (
                <div className="post-card__thumb">
                    <img src={imageUrl} alt={title} loading="lazy" />
                </div>
            )}

            <div className="post-card__body">
                <div className="post-card__meta">
                    <time dateTime={date}>{date}</time>
                    {category && (
                        <span className="post-card__category">{category}</span>
                    )}
                </div>
                <h2 className="post-card__title">{title}</h2>
                <p className="post-card__excerpt">{excerpt}</p>
            </div>
        </article>
    );
}

export default PostCard;

// 使い方(App.tsxなど)
import PostCard from './components/PostCard';

function App() {
    return (
        <PostCard
            title="CSSのpositionプロパティを理解する"
            excerpt="static・relative・absolute・fixed・stickyの違いを解説します。"
            date="2025-01-15"
            category="CSS"
        />
    );
}

💡 imageUrl && (...) はJavaScriptの短絡評価を使った条件付きレンダリングです: imageUrlundefined(falsy)なら何も表示せず、値がある(truthy)場合だけ <div> を表示します。


5. 繰り返しレンダリング(リスト表示)

5.1 配列をmapでコンポーネントに変換する

// src/components/PostList.tsx
import PostCard from './PostCard';

interface Post {
    id:       number;
    title:    string;
    excerpt:  string;
    date:     string;
    category: string;
}

interface PostListProps {
    posts: Post[];
}

function PostList( { posts }: PostListProps ) {
    if ( posts.length === 0 ) {
        return <p className="no-posts">投稿がありません。</p>;
    }

    return (
        <div className="posts-grid">
            {posts.map( post => (
                // keyは必須(同じ階層で一意な値を使う)
                <PostCard
                    key={post.id}
                    title={post.title}
                    excerpt={post.excerpt}
                    date={post.date}
                    category={post.category}
                />
            ) )}
        </div>
    );
}

export default PostList;

⚠️ key プロパティは必須です: Reactはリストを更新するとき key を使ってどの要素が変わったかを判断します。key がないとパフォーマンスの問題や予期しない表示バグが起きます。key にはインデックス(index)ではなく、安定したユニークなID(post.id など)を使いましょう。


6. Hooks(フック)の基本

6.1 useState:状態を管理する

// src/components/Counter.tsx
import { useState } from 'react';

function Counter() {
    // [現在の値, 値を更新する関数] = useState(初期値)
    const [count, setCount] = useState<number>( 0 );

    return (
        <div className="counter">
            <p>カウント:{count}</p>
            <button onClick={() => setCount( count + 1 )}>増やす</button>
            <button onClick={() => setCount( count - 1 )}>減らす</button>
            <button onClick={() => setCount( 0 )}>リセット</button>
        </div>
    );
}

6.2 useStateでオブジェクトを管理する

// フォームの状態管理
interface FormData {
    name:    string;
    email:   string;
    message: string;
}

function ContactForm() {
    const [formData, setFormData] = useState<FormData>( {
        name:    '',
        email:   '',
        message: '',
    } );

    const [isSubmitting, setIsSubmitting] = useState( false );
    const [successMessage, setSuccessMessage] = useState<string | null>( null );

    // 汎用の入力ハンドラー
    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    ) => {
        const { name, value } = e.target;
        setFormData( prev => ({ ...prev, [name]: value }) );
        //               ↑ 前の状態を展開して該当フィールドだけ更新する
    };

    const handleSubmit = async ( e: React.FormEvent<HTMLFormElement> ) => {
        e.preventDefault();
        setIsSubmitting( true );

        try {
            // APIリクエスト(前回学んだfetch)
            await fetch( '/api/contact', {
                method: 'POST',
                body:   JSON.stringify( formData ),
                headers: { 'Content-Type': 'application/json' },
            } );
            setSuccessMessage( 'お問い合わせを受け付けました。' );
            setFormData( { name: '', email: '', message: '' } );
        } catch {
            alert( '送信に失敗しました。再度お試しください。' );
        } finally {
            setIsSubmitting( false );
        }
    };

    return (
        <form onSubmit={handleSubmit} className="contact-form">
            {successMessage && (
                <div className="form-message form-message--success" role="alert">
                    {successMessage}
                </div>
            )}

            <div className="form-group">
                <label htmlFor="name" className="form-label">お名前</label>
                <input
                    type="text"
                    id="name"
                    name="name"
                    value={formData.name}
                    onChange={handleChange}
                    className="form-input"
                    required
                />
            </div>

            <div className="form-group">
                <label htmlFor="email" className="form-label">メールアドレス</label>
                <input
                    type="email"
                    id="email"
                    name="email"
                    value={formData.email}
                    onChange={handleChange}
                    className="form-input"
                    required
                />
            </div>

            <div className="form-group">
                <label htmlFor="message" className="form-label">お問い合わせ内容</label>
                <textarea
                    id="message"
                    name="message"
                    value={formData.message}
                    onChange={handleChange}
                    className="form-textarea"
                    rows={6}
                    required
                />
            </div>

            <button
                type="submit"
                className={`form-submit ${isSubmitting ? 'is-loading' : ''}`}
                disabled={isSubmitting}
            >
                {isSubmitting ? '送信中...' : '送信する'}
            </button>
        </form>
    );
}

6.3 useEffect:副作用を管理する

// src/components/PostList.tsx
import { useState, useEffect } from 'react';
import PostCard from './PostCard';

interface Post {
    id:    number;
    title: string;
    body:  string;
}

function PostList() {
    const [posts,     setPosts]     = useState<Post[]>( [] );
    const [isLoading, setIsLoading] = useState( true );
    const [error,     setError]     = useState<string | null>( null );

    // コンポーネントがマウントされたときにAPIを呼ぶ
    useEffect( () => {
        const fetchPosts = async () => {
            try {
                const res  = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=6' );
                const data = await res.json() as Post[];
                setPosts( data );
            } catch {
                setError( '投稿の読み込みに失敗しました。' );
            } finally {
                setIsLoading( false );
            }
        };

        fetchPosts();
    }, [] ); // ← 空配列:マウント時に1度だけ実行する

    if ( isLoading ) return <div className="loading">読み込み中...</div>;
    if ( error )     return <div className="error" role="alert">{error}</div>;

    return (
        <div className="posts-grid">
            {posts.map( post => (
                <PostCard
                    key={post.id}
                    title={post.title}
                    excerpt={post.body}
                    date="2025-01-01"
                />
            ) )}
        </div>
    );
}

export default PostList;

💡 useEffect の第2引数の配列は「依存配列」です:

useEffect( () => { /* 処理 */ } );          // ❌ 毎レンダーで実行(無限ループの危険)
useEffect( () => { /* 処理 */ }, [] );      // ✅ マウント時に1度だけ実行
useEffect( () => { /* 処理 */ }, [id] );    // ✅ idが変わるたびに実行

7. カスタムフックで処理を共通化する

7.1 データ取得ロジックをカスタムフックに切り出す

// src/hooks/useFetch.ts
import { useState, useEffect } from 'react';

interface FetchState<T> {
    data:      T | null;
    isLoading: boolean;
    error:     string | null;
}

// ジェネリクスを使って汎用的なカスタムフックを作る
function useFetch<T>( url: string ): FetchState<T> {
    const [data,      setData]      = useState<T | null>( null );
    const [isLoading, setIsLoading] = useState( true );
    const [error,     setError]     = useState<string | null>( null );

    useEffect( () => {
        let isCancelled = false; // クリーンアップフラグ

        const fetchData = async () => {
            setIsLoading( true );
            setError( null );

            try {
                const res = await fetch( url );
                if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
                const json = await res.json() as T;
                if ( !isCancelled ) setData( json );
            } catch ( err ) {
                if ( !isCancelled ) {
                    setError( err instanceof Error ? err.message : '不明なエラー' );
                }
            } finally {
                if ( !isCancelled ) setIsLoading( false );
            }
        };

        fetchData();

        // クリーンアップ関数:コンポーネントがアンマウントされたら実行
        return () => {
            isCancelled = true;
        };
    }, [url] ); // urlが変わるたびに再取得する

    return { data, isLoading, error };
}

export default useFetch;

// 使い方(コンポーネント側がすっきりする)
import useFetch from '../hooks/useFetch';

function PostList() {
    const { data: posts, isLoading, error } = useFetch<Post[]>(
        'https://jsonplaceholder.typicode.com/posts?_limit=6'
    );

    if ( isLoading ) return <div>読み込み中...</div>;
    if ( error )     return <div>{error}</div>;
    if ( !posts )    return null;

    return (
        <div className="posts-grid">
            {posts.map( post => (
                <PostCard key={post.id} title={post.title} excerpt={post.body} date="" />
            ) )}
        </div>
    );
}

8. CSSのあて方

8.1 グローバルCSS

// src/main.tsx でimportする
import './index.css'; // グローバルCSS

8.2 CSSモジュール

/* src/components/PostCard.module.css */
.card {
    background:    white;
    border-radius: 8px;
    overflow:      hidden;
    box-shadow:    0 2px 8px rgba( 0, 0, 0, 0.08 );
    transition:    transform 0.25s ease, box-shadow 0.25s ease;
}

.card:hover {
    transform:  translateY( -4px );
    box-shadow: 0 8px 24px rgba( 0, 0, 0, 0.12 );
}

.title {
    font-size:   1.8rem;
    font-weight: 700;
    line-height: 1.4;
}
// src/components/PostCard.tsx
import styles from './PostCard.module.css';

function PostCard( { title, excerpt }: PostCardProps ) {
    return (
        <article className={styles.card}>
            <h2 className={styles.title}>{title}</h2>
            <p>{excerpt}</p>
        </article>
    );
}

💡 CSSモジュールはクラス名の衝突を自動的に防ぎます: ビルド時にクラス名が PostCard_card__Abc12 のようにユニークな名前に変換されるため、他のコンポーネントのCSSと衝突しません。


9. コンポーネントの分割指針

9.1 どこでコンポーネントを分割するか

分割の目安:

① 同じUIを2箇所以上で使う → コンポーネント化する
② 1つのコンポーネントが100行を超えてきた → 分割を検討する
③ 異なる関心事が1コンポーネントに混在している → 分割する
④ 再利用はしないがロジックが複雑 → カスタムフックに切り出す

9.2 ディレクトリ構成の例

src/
├── components/          ← 再利用可能なUIコンポーネント
│   ├── ui/             ← 汎用的なUI部品
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Card.tsx
│   └── features/       ← 機能ごとのコンポーネント
│       ├── PostCard.tsx
│       ├── PostList.tsx
│       └── ContactForm.tsx
├── hooks/              ← カスタムフック
│   ├── useFetch.ts
│   └── useLocalStorage.ts
├── types/              ← 型定義
│   ├── post.ts
│   └── user.ts
├── utils/              ← ユーティリティ関数
│   └── formatters.ts
├── App.tsx
└── main.tsx

10. 実践チェックリスト

npm create vite@latest でReact + TypeScriptプロジェクトを作成したか

JSXの classNamehtmlFor・自己終了タグなどHTMLとの違いを理解したか

コンポーネントのpropsに interface で型を定義しているか

useState で状態を管理して setState 以外の方法で状態を変更していないか

リストを map() でレンダリングするとき key を設定しているか

useEffect の依存配列を正しく設定しているか

繰り返し使うデータ取得ロジックをカスタムフックに切り出したか

CSSモジュールまたはグローバルCSSでスタイルを適用しているか

コンポーネントが大きくなりすぎていないか(100行を目安に分割を検討)

npm run build でビルドエラーがないか確認したか


まとめ

今回はReact + ViteでコンポーネントベースのUI開発を始める方法を解説しました。ポイントをまとめると:

  • ReactはUIをコンポーネントという再利用可能な部品に分割して管理するライブラリ
  • JSXはJavaScriptの中にHTMLのような記法を書ける構文拡張で classNamehtmlFor・自己終了タグなどHTMLとの違いがある
  • propsで親から子コンポーネントに値を渡して interface で型を定義する
  • useState で状態を管理して値を変更するときは必ず setState 関数を使う
  • リストを map() でレンダリングするときは key に安定したユニークIDを指定する
  • useEffect はマウント時・依存値が変わったとき・アンマウント時の処理を管理する
  • データ取得のロジックはカスタムフックに切り出すと複数コンポーネントで再利用できる
  • CSSモジュールを使うとクラス名の衝突を自動的に防げる

次の記事では、ReactのルーティングライブラリTanStack Router(または React Router)を使ってページ遷移を実装する方法を解説します。お楽しみに!

コメント

タイトルとURLをコピーしました