React入門

2024-2025 Shunsuke Watanabe

このチュートリアルについて

このチュートリアルはReactでSPA(Single Page Application)を構築する際の基礎に触れてもらうことを目的としています。

twitchまたはYoutubeで配信します、配信時間は2時間前後です。

Twitch → https://www.twitch.tv/shun__suke__

Youtube → https://www.youtube.com/@Captain_Emo

🧭 配信予定 → https://hands-on.connpass.com/

discord → https://discord.gg/3havwjWGsw

bluesky → https://bsky.app/profile/craftgear.bsky.social

📝 blog → https://craftgear.github.io/posts/

このチュートリアルを終えたあとの演習として、ハンズオンシリーズがあります。→ ハンズオン一覧

目次

1. 開発環境の準備

対象環境

Node.js

コードエディタ


新しいReactプロジェクトの作成

  1. Viteを使って初期化

    Terminal window
    npm create vite
  2. プロジェクト名を入力し、ReactTypeScript + SWCを選択してください

    スクリーンショット 2024-08-10 22.10.44.png

  3. Linter

    • インストール済み
  4. コードフォーマッターのインストール

    npm install --save-dev prettier
  5. npm run dev で開発用サーバ起動

2. コンポーネント作成

コンポーネントとJSX

  1. Viteアイコンをコンポーネントにする

    const ViteIcon = () => {
    return (
    <a href="https://vitejs.dev" target="_blank">
    <img src={viteLogo} className="logo" alt="Vite logo" />
    </a>
    );
    };
    • Reactアイコンをコンポーネントにする
  2. Viteアイコンコンポーネントを別ファイルにする

    • Reactアイコンコンポーネントを別ファイルにする
  3. Viteアイコンに <div>アイコンをクリックするとViteのサイトを開きます</div> というタグを追加する

    • Reactアイコンに <div>アイコンをクリックするとReactのサイトを開きます</div> というタグを追加する
  4. components ディレクトリを作成し、ViteIcon.tsxReactIcon.tsx をその中に移す

コンポーネント

JSX

コンポーネントのレンダリングとDOMレンダリング

react-scanのセットアップ

React-Scan を使ったReactレンダリングの可視化

スクリーンショット 2025-01-28 17.12.27.png

Chrome DevTools を使った DOMレンダリングの可視化

スクリーンショット 2024-06-03 10.38.37.png

スクリーンショット 2024-06-03 10.39.19.png

コンポーネントの属性

  1. 「こんにちは」と出力する Greeting コンポーネントを作り、App.tsxで表示する。

    export const Greeting = () => {
    return <h1>こんにちは</h1>
    }
  2. <Greeting name="React" /> で名前を受け取り、「こんにちは React」と出力するように変更する

    type Props = {
    name: string;
    };
    export const Greeting = (props: Props) => {
    return <h1>こんにちは {props.name}</h1>
    }
  3. <Greeting /> コンポーネントのタグを開き、子要素を追加する

    import { PropsWithChildren } from 'react';
    type Props = {
    name: string;
    };
    export const Greeting = (props: PropsWithChildren<Props>) => {
    return (
    <>
    <h1>こんにちは {props.name}</h1>
    {props.children}
    </>
    );
    };
    • props を destructuring する

Props

Children

  1. 名前の配列を元に複数のコンポーネントを作成する

    export const Greeting = ({ names, children }: PropsWithChildren<Props>) => {
    return (
    <>
    {names.map((name) => (
    <h1 key={name}>こんにちは {name}</h1>
    ))}
    {children}
    </>
    );
    };

Key

補足

まとめ

3. スタイル

CSS適用方法4つ

React@18以降の状況

DaisyUI

  1. Tailwind CSSのインストール

    npm install -D tailwindcss @tailwindcss/vite
  2. vite.config.ts に tailwindcssプラグインを追加

    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react-swc'
    import tailwindcss from '@tailwindcss/vite'
    // https://vitejs.dev/config/
    export default defineConfig({
    plugins: [react(), tailwindcss()],
    })
  3. .prettierrc.json というファイルを作り、 prettier-plugin-tailwindcss を追加

    daisyUIのログがエディタに反映されるというバグがあるので使わないこと https://github.com/tailwindlabs/tailwindcss/discussions/8380

    {
    "semi": false,
    "singleQuote": true,
    "plugins": ["prettier-plugin-tailwindcss"],
    }
  4. index.css の中身を消し、Tailwind CSSを読み込み

    @import "tailwindcss";
  5. DaisyUIのインストール

    npm i -D daisyui@latest
  6. index.css で DaisyUIプラグインを読み込み

    @import "tailwindcss";
    @plugin "daisyui" {
    themes: synthwave --default;
    }
  7. 開発サーバーを再起動

  8. カウンターボタンの見た目を変更する

    • tailwindcssを使い、文字サイズ、ボタンの幅を変更する
    • daisyuiを使い、ボタンの見た目を変更する

4. フック

useState

  1. react-scanでレンダリング表示を有効にする
  2. <div className="card">...</div><Counter/> コンポーネントとして分離する
  3. setCount((count) => count+1)setCount(count+1) に変更しておく。
import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0)
return (
<div className="card">
<button onClick={() => setCount(count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
)
}
  1. react-scanを有効にし、App.tsxのカウンターを更新したときと、<Counter /> コンポーネントを更新したときのレンダリングの違いを確認する
  2. <Counter /> をコピーして <ObjectCounter /> を作成し、useStateで保存する値をオブジェクトに変更する。
import { useState } from 'react';
export const ObjectCounter = () => {
const [state, setState] = useState({ count: 0 });
return (
<div className="card">
<button
className="btn btn-primary text-xl"
onClick={() => {
state.count = state.count + 1;
setState(state);
}}
>
count is {state.count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
);
};
  1. オブジェクト内の値を直に書き換えてもコンポーネントが再レンダリングされないことを確認する。
  1. previousValueを使って振る舞いの違いを確認する
  1. previousValueを使って2ずつ増えるように変更する

useRef

useEffect


  1. <Counter /> コンポーネントのボタンにrefを使ってフォーカスを当てる
  2. ページ表示時にボタンまでスクロールするように変更する
  3. クリーンアップ関数の振る舞いを確認する

覚える値の種類と値の更新方法の組み合わせ

フック覚える値更新方法再レンダリング
useState任意の値関数呼び出しYes
useRef任意の値(主にDOM)代入No
useEffect関数依存配列の値の変更Yes
useReducer任意の値関数呼び出しYes
useMemo任意の値依存配列の値の変更Yes
useCallback関数依存配列の値の変更Yes
useLayoutEffect関数依存配列の値の変更Yes

サードパーティ製のカスタムフック

https://github.com/streamich/react-use

補足

useMemo / useCallback (& React.memo)

以前の内容
  1. <Note />React.memo() を使ってメモ化し、再レンダリングされないことを確認する
  2. <Counter /> から <Note />const now = Date.now() を渡し、再レンダリングされるのを確認する
  3. useMemo を使い、依存配列がない場合、空の依存配列がある場合、別のステートで再レンダリングされた場合で違いを確認する。
  4. const handleClick = () => setState(counter + 1)<Note /> コンポーネントに渡し、再レンダリングされるのを確認する。
  5. useCallback を使い、依存配列がない場合、空の依存配列がある場合、別のステートで再レンダリングされた場合で <Note/> コンポーネントの再レンダリングを確認する。
  6. const handleClick = () => setState(counter + 1) を prevValue を使って書き換え、依存配列が空の場合にも再レンダリングが起きないことを確認する。

useLayoutEffect

useReducer

5. ページルーティング


  1. TanStack Routerをインストール

    Terminal window
    npm i @tanstack/react-router; npm i -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/eslint-plugin-router
  2. vite.config.ts にTanStack Routerプラグインの設定を追加

    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react-swc";
    import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
    // https://vite.dev/config/
    export default defineConfig({
    plugins: [
    react(),
    TanStackRouterVite()
    ],
    });
  3. src/routes/__root.tsx ファイルを作成する

  4. npm run dev を実行 ← ここ重要

  5. App.tsx の中身を src/routes/index.tsx として切り出す

  6. 新しいページを作成する src/routes/todos.tsx

  7. App.tsx でルーターを読み込む

    import "./App.css";
    import { RouterProvider, createRouter } from "@tanstack/react-router";
    import { routeTree } from "./routeTree.gen"; // 自動生成されるファイル
    const router = createRouter({ routeTree });
    declare module "@tanstack/react-router" {
    interface Register {
    router: typeof router;
    }
    }
    function App() {
    return (
    <RouterProvider router={router} />
    );
    }
    export default App;
  8. src/routes/__root.tsx にナビゲーションを追加し、ルートを切り替えるとカウンターがリセットされることを確認する

...
<div className="navbar flex justify-around w-full">
<Link className="btn btn-ghost" to="/">
Home
</Link>
<Link className="btn btn-ghost" to="/todos">
Todos
</Link>
</div>
...

6. アプリケーションステート

TanStack Store

  1. インストール

    npm install @tanstack/react-store
  2. src/state/counter.ts ファイルを作成し、counterステートを定義する

    import { Store } from "@tanstack/react-store";
    export const counterStore = new Store({
    counter: 0,
    });
  3. <Counter /> コンポーネントをコピーして<CounterWithStore/> コンポーネントを作り、 useStateuseStore で置き換える。

    import {
    useState,
    } from 'react';
    import { useStore } from "@tanstack/react-store";
    import { counterStore } from "../state/counter";
    export const CounterWithStore = () => {
    const [count, setCount] = useState(0);
    const count = useStore(counterStore, (store) => store["counter"]);
    const handleClick = () => {
    // setCount(count + 1);
    setCount((prevCount) => prevCount + 1);
    counterStore.setState((state) => {
    return {
    ...state,
    counter: state.counter + 1,
    };
    });
    };
    return (
    <div className="card flex flex-col items-center">
    <button
    className="btn 󰝤 btn-primary w-1/2 text-xl"
    onClick={handleClick}
    >
    count is {count}
    </button>
    <p>
    Edit <code>src/App.tsx</code> and save to test HMR
    </p>
    </div>
    );
    };
  4. Index.tsx<CounterWithStore /> をインポートする。

  5. Todoルートに遷移後、Indexに戻ってくるとカウントが保持されていることを確認する。

  6. src/state/counter.tscounterStoreを直接エクスポートする代わりに useCounterカスタムフックを作成し、エクスポートする。

    import { Store, useStore } from "@tanstack/react-store";
    export const counterStore = new Store({
    counter: 0,
    });
    const setCounter = () => {
    counterStore.setState((store) => {
    return {
    ...store,
    counter: store.counter + 1,
    };
    });
    };
    export const useCounter = (): [number, typeof setCounter] => [
    useStore(counterStore, (store) => store["counter"]),
    setCounter,
    ];
  7. src/state/counter.ts の状態に messageuseMessage を追加する

    // ...
    const counterStore = new Store({
    counter: 0,
    message: "10未満",
    });
    const setCounter = () => {
    counterStore.setState((store) => {
    return {
    ...store,
    count: store.counter + 1,
    message: store.counter + 1 >= 10 ? "10以上" : store.message,
    };
    });
    };
    // ...
    export const useMessage = () =>
    useStore(counterStore, (store) => store["message"]);
  8. <Greeting /> コンポーネントで message を表示する

  9. カウンターをクリックして数字を増やしても、<Greeting /> が再描画されていないことを確認する。

7. サーバーステート

  1. 下準備:モックサーバーの準備
Terminal window
npm i -D json-server
{
"todos": [
{
"id": "1",
"done": false,
"title": "やること1",
"createdAt": "2024-05-01",
"doneAt": null
},
{
"id": "2",
"done": false,
"title": "やること2",
"createdAt": "2024-06-01",
"doneAt": null
}
]
}
npx json-server db.json --watch
  1. 下準備:fetchの追加
const SERVER_URL = 'http://localhost:3000/todos';
export const get = async () => {
const response = await fetch(SERVER_URL);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
}
const patch = async (data: { id: string; done: boolean }) => {
const res = await fetch(`${SERVER_URL}/${data.id}`, {
method: 'PATCH',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(res.statusText);
}
return res.json();
};
const post = async (data: { title: string }) => {
const response = await fetch(SERVER_URL, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
};
  1. tanstack-queryをインストール
Terminal window
npm i @tanstack/react-query @tanstack/react-query-devtools

https://tanstack.com/query/latest/docs/eslint/eslint-plugin-query

  1. <App />コンポーネントに QueryClientProvider を追加する
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ...
const client = new QueryClient();
function App() {
return (
<QueryClientProvider client={client}>
// ...
</QueryClientProvider>
);
}
  1. <Todos /> コンポーネントでモックサーバーからデータを取得する
import { get } from '../api/todos';
import { useQuery } from "@tanstack/react-query";
// ...
export const Todos = () => {
// ...
const {data, isPending, error} = useQuery({
queryKey: ['todos'],
queryFn: get,
})
if (isPending) {
return <div>Loading ...</div>;
}
if (error) {
return <div>Error</div>;
}
console.log('----- data', data);
// ...
}
  1. src/api/todos.tsファイルにカスタムフック useGetTodos を追加する
import { useQuery } from '@tanstack/react-query';
// ...
type Todo = {
id: number;
title: string;
done: boolean;
};
const queryKey = ['todos'];
export const useGetTodos = () => {
return useQuery<Todo[]>({
queryKey,
queryFn: get,
});
};
  1. <Todos /> コンポーネントのデータ取得処理を useGetTodos フックで置き換える
import { get useGetTodos } from '../api/todos';
import { useQuery } from "@tanstack/react-query";
// ...
export const Todos = () => {
// ...
const {data, isPending, error} = useQuery({
queryKey: ['todos'],
queryFn: get,
})
const { data, isPending, error } = useGetTodos();
// ...
}
  1. src/api/todos.tsファイルにカスタムフック usePatchTodo を作る
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
// ...
export const usePatchTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: patch,
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
};
  1. チェックボックスのオンオフでデータを更新する
import { ChangeEvent } from 'react';
import { useGetTodos, usePatchTodo } from '../api/todos';
//...
const { mutate } = usePatchTodo();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const id = e.target.value;
mutate({ id, done: e.target.checked });
};
// ...
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-3xl font-bold mb-6 text-center">Todo List</h1>
<ul className="space-y-3">
{data?.map(({ id, title, done }) => (
<li
key={id}
className="flex items-center p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow bg-neutral"
>
<input
type="checkbox"
id={`${id}`}
value={id}
checked={done ? true : false}
onChange={handleChange}
className="w-5 h-5 mr-4 cursor-pointer"
/>
<label
htmlFor={`${id}`}
className={`flex-1 text-lg ${done ? "line-through 󰝤 text-gray-400" : "󰝤 text-gray-50"}`}
>
{title}
</label>
</li>
))}
</ul>
</div>
);

補足

import { queryOptions } from '@tanstack/react-query'
const groupOptions = (id: number) =>
queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
staleTime: 5 * 1000,
});
// usage:
useQuery(groupOptions(1))
useSuspenseQuery(groupOptions(5))
useQueries({
queries: [groupOptions(1), groupOptions(2)],
})
queryClient.prefetchQuery(groupOptions(23))
queryClient.setQueryData(groupOptions(42).queryKey, newGroups)

8. フォーム

  1. react-hook-formをインストールする

    npm i react-hook-form
  2. Todoを新規登録するためのAPI呼び出しを src/api/todosusePostTodo フックとして追加する

    export const usePostTodo = () => {
    const queryClient = useQueryClient();
    return useMutation({
    mutationFn: post,
    onSuccess: () => queryClient.invalidateQueries({ queryKey }),
    });
    };
  3. componentsディレクトリに TodoForm.tsx を新たに作成する

    import { useForm, SubmitHandler } from 'react-hook-form';
    import { usePostTodo } from '../api/todos';
    type FormData = {
    title: string;
    };
    export const TodoForm = () => {
    const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
    } = useForm<FormData>();
    const { mutate } = usePostTodo();
    const submitForm: SubmitHandler<FormData> = (data) => {
    if (!data.title) return;
    mutate({ title: data.title }, { onSuccess: () => reset() });
    };
    return (
    <form onSubmit={handleSubmit(submitForm)} className="m-5">
    {errors.title && <p>タイトルを入力してください</p>}
    <input type="text" {...register('title', { required: 'true' })} />
    <button className="btn btn-primary btn-sm">追加</button>
    </form>
    );
    };
  4. pages/Todos.tsxTodoForm.tsx をインポートする

入力値のチェック

9. 関連ライブラリ、SaaSなど

フレームワーク

ドラッグアンドドロップ

認証

多言語化

バリデーション

className操作

日付処理

アイコン

グラフ描画

metaタグ書き換え

WiSYWIGエディタ

10. ユニットテスト

  1. playwrightをインストール

    npm init playwright@latest -- --ct
  2. playwright-ct.config.js の内容を下記に変更

    // @ts-check
    import { defineConfig, devices } from '@playwright/experimental-ct-react';
    /**
    * @see https://playwright.dev/docs/test-configuration
    */
    export default defineConfig({
    testDir: './',
    /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */
    snapshotDir: './__snapshots__',
    /* Maximum time one test can run for. */
    timeout: 10 * 1000,
    /* Run tests in files in parallel */
    fullyParallel: true,
    /* Fail the build on CI if you accidentally left test.only in the source code. */
    forbidOnly: !!process.env.CI,
    /* Retry on CI only */
    retries: process.env.CI ? 2 : 0,
    /* Opt out of parallel tests on CI. */
    workers: process.env.CI ? 1 : undefined,
    /* Reporter to use. See https://playwright.dev/docs/test-reporters */
    reporter: 'list',
    /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
    use: {
    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
    trace: 'on-first-retry',
    /* Port to use for Playwright component endpoint. */
    ctPort: 3100,
    },
    /* Configure projects for major browsers */
    projects: [
    {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], colorScheme: 'dark' },
    },
    {
    name: 'firefox',
    use: { ...devices['Desktop Firefox'], colorScheme: 'dark' },
    },
    {
    name: 'webkit',
    use: { ...devices['Desktop Safari'], colorScheme: 'dark' },
    },
    ],
    });
  3. /playwright/index.tsx にスタイルシートのインポートを追加

    import '../src/index.css';
    import '../src/App.css';
  4. /src/App.spec.tsx を作成

    import { test, expect } from '@playwright/experimental-ct-react';
    import App from './App';
    test.use({ viewport: { width: 500, height: 500 } });
    test.beforeEach(async ({ page }) => {
    await page.clock.setFixedTime('2024-01-01');
    });
    test('should work', async ({ mount }) => {
    const component = await mount(<App />);
    await expect(component).toContainText('こんにちは React');
    await expect(component).toHaveScreenshot();
    });
  5. テストを実行

    npm run test-ct
    • 初回はスクリーンショットがないため失敗します
    • 2回目の実行でテストが成功します
    • __snapshots__ ディレクトリの下にコンポーネントのキャプチャ画像が格納されています