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

🧭 配信予定はconnpassをご確認ください → https://hands-on.connpass.com/

👨‍💻サンプルコード → https://github.com/craftgear/react-hands-on

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@3 postcss autoprefixer @tailwindcss/typography
    npx tailwindcss init -p
  2. tailwind.config.js を更新

    /** @type {import('tailwindcss').Config} */
    import typography from '@tailwindcss/typography';
    export default {
    **content: ['./index.html', './src/**/*.{jsx,tsx,js,html}'],**
    theme: {
    extend: {},
    },
    plugins: [typography],
    };
  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のクラスで置き換え

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
  5. DaisyUIのインストール

    npm i -D daisyui@4
  6. tailwind.config.js で DaisyUIプラグインを読み込み

    import typography from '@tailwindcss/typography';
    import daisyui from "daisyui"
    module.exports = {
    //...
    plugins: [typography, daisyui],
    daisyui: {
    // daisyui config https://daisyui.com/docs/config/
    }
    }
  7. 開発サーバーを再起動

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

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

4. フック

useState

  1. react-scanでレンダリング表示を有効にする
  2. <div className="card">...</div><Counter/> コンポーネントとしてコピーする a. 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プリミティブ、配列、オブジェクト関数呼び出し
useRef任意の値(主にDOM)手動/自動
useEffect関数依存配列の値の変更

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

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. wouterをインストール
Terminal window
npm i wouter
  1. App.tsx の中身を src/pages/Index.tsx として切り出す
  2. 新しいページを作成する src/pages/Todos.tsx
  3. App.tsx にルートを追加し、ルートを切り替えるとカウンターがリセットされることを確認する
<div className="navbar flex justify-around w-full">
<Link className="btn btn-ghost" href="/">
Home
</Link>
<Link className="btn btn-ghost" href="/todos">
Todos
</Link>
</div>
<Switch>
<Route path="/" component={Index} />
<Route path="/todos" component={Todos} />
</Switch>

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,
    PropsWithChildren,
    } from 'react';
    import { Note } from './Note';
    import { useStore } from "@tanstack/react-store";
    import { counterStore } from "../state/counter";
    export const CounterWithStore = ({ children }: PropsWithChildren) => {
    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.counterer + 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>
    {children}
    <Note now={memoizedNow} handleClick={handleClick} />
    </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({
    count: 0,
    message: "10未満",
    });
    const setCounter = () => {
    counterStore.setState((store) => {
    return {
    ...store,
    count: store.count + 1,
    message: store.count + 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-accent-content"
>
<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エディタ