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. 開発環境の準備
対象環境
- macOS
- Node.js 20〜
- LinuxやWindowsの方は自分でnodeとnpmをインストールできれば後は同じです
Node.js
-
nodeとnpmのバージョン確認
Terminal window ❯ node -vv20.13.1❯ npm -v10.5.1
コードエディタ
- VisualStudio Code, neovim, emacsなどプログラミング支援機能が使えるもの
新しいReactプロジェクトの作成
-
Viteを使って初期化
Terminal window npm create vite -
プロジェクト名を入力し、
React
とTypeScript + SWC
を選択してください -
Linter
- インストール済み
-
コードフォーマッターのインストール
npm install --save-dev prettier -
npm run dev
で開発用サーバ起動- http://localhost:5173 をブラウザで開く
2. コンポーネント作成
コンポーネントとJSX
-
Viteアイコンをコンポーネントにする
const ViteIcon = () => {return (<a href="https://vitejs.dev" target="_blank"><img src={viteLogo} className="logo" alt="Vite logo" /></a>);};- Reactアイコンをコンポーネントにする
-
Viteアイコンコンポーネントを別ファイルにする
- Reactアイコンコンポーネントを別ファイルにする
-
Viteアイコンに
<div>アイコンをクリックするとViteのサイトを開きます</div>
というタグを追加する- Reactアイコンに
<div>アイコンをクリックするとReactのサイトを開きます</div>
というタグを追加する
- Reactアイコンに
-
components
ディレクトリを作成し、ViteIcon.tsx
とReactIcon.tsx
をその中に移す
コンポーネント
- コンポーネント = 関数
- 関数名は大文字で始める
- ファイルの拡張子は
tsx
- 要素を一つだけ返す
- 複数行のjsxは ( ) でくくる
- 別ファイルのコンポーネントはexportする
JSX
- HTMLタグではない
- https://babeljs.io/repl
- jsxの特殊な属性
- class → className
- for → htmlFor
- key
- ref
- fragment
- jsx内にjavascriptを書くには
{}
を使う
コンポーネントのレンダリングとDOMレンダリング
react-scanのセットアップ
-
index.html
を開き、head
タグ内に以下のスクリプトを追加する<script src="https://unpkg.com/react-scan/dist/auto.global.js"></script>
React-Scan を使ったReactレンダリングの可視化
- ページをリロードすると左上にreact-scanのウィジェットが表示される
Chrome DevTools を使った DOMレンダリングの可視化
コンポーネントの属性
-
「こんにちは」と出力する
Greeting
コンポーネントを作り、App.tsxで表示する。export const Greeting = () => {return <h1>こんにちは</h1>} -
<Greeting name="React" />
で名前を受け取り、「こんにちは React」と出力するように変更するtype Props = {name: string;};export const Greeting = (props: Props) => {return <h1>こんにちは {props.name}</h1>} -
<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
- タグの属性に任意の値を渡すとpropsとして受け取れる
- propsが変わるとコンポーネントが再レンダリングされる
- propsには型をつける
Children
- コンポーネントの子要素が
children
としてpropsに渡される - 型情報の追加には
PropsWithChildren
ユーティリティタイプを使う
-
名前の配列を元に複数のコンポーネントを作成する
export const Greeting = ({ names, children }: PropsWithChildren<Props>) => {return (<>{names.map((name) => (<h1 key={name}>こんにちは {name}</h1>))}{children}</>);};
Key
- 同じコンポーネントを動的に複数生成するとき key がないとコンソールに警告が出る
- keyは親要素ごとにユニークであればいい
- propsには含まれない
- keyが変わるとコンポーネントが再マウントされる
補足
class HogeHoge extends React.Component
という書式について- コンポーネント内でコンポーネントを定義するのは避ける https://react.dev/learn/your-first-component#nesting-and-organizing-components
まとめ
UI = f( props )
- jsx ≠ HTML
- コンポーネントのレンダリング ≠ DOMのレンダリング
3. スタイル
CSS適用方法4つ
- グローバルCSS
- 通常のスタイルシートと同じ
- CSS Module
- コンポーネントごとにCSSクラスをスコープ化できる
- インラインStyle
- コンポーネントに
style
属性を追加する
- コンポーネントに
- CSS-in-JS
- jsx内でcssをかけるようにする仕組み
- emotion
- emotionベースのコンポーネントライブラリ
- ChakraUI / MUI / Antd / Mantine
React@18以降の状況
- CSS-in-JSはReact18以降で描画が遅くなる
- zero runtime CSS-in-JS
- vanilla-extract https://vanilla-extract.style/
- linaria https://linaria.dev/
- panda css https://panda-css.com/
- Griffel https://griffel.js.org/
- Tailwind CSS ベースのコンポーネントライブラリ
- shadcn/ui https://ui.shadcn.com/
- Flowbite React https://flowbite-react.com/
- DaisyUI https://daisyui.com/
- HyperUI https://www.hyperui.dev/
- Preline https://preline.co/examples.html
DaisyUI
-
Tailwind CSSのインストール
npm install -D tailwindcss@3 postcss autoprefixer @tailwindcss/typographynpx tailwindcss init -p -
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],}; -
.prettierrc.json
というファイルを作り、prettier-plugin-tailwindcss
を追加daisyUIのログがエディタに反映されるというバグがあるので使わないこと https://github.com/tailwindlabs/tailwindcss/discussions/8380
{"semi": false,"singleQuote": true,"plugins": ["prettier-plugin-tailwindcss"],} -
index.css
をTailwind CSSのクラスで置き換え@tailwind base;@tailwind components;@tailwind utilities; -
DaisyUIのインストール
npm i -D daisyui@4 -
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/}} -
開発サーバーを再起動
-
カウンターボタンの見た目を変更する
- tailwindcssを使い、文字サイズ、ボタンの幅を変更する
- daisyuiを使い、ボタンの見た目を変更する
4. フック
- Reactが標準で提供するフック https://react.dev/reference/react/hooks
- とりあえず覚えると良いもの
- useState
- useRef
- useEffect
- ある時点での値を記憶しておく仕組み
- 覚える値の種類と値の更新方法の違いで色々種類がある
- レンダリング時に毎回同じフックが同じ順番で呼び出される必要がある
- if 文で分岐はできない
useState
const [value, setValue] = useState(initialValue)
setValue
を使って値を変更するとコンポーネントがレンダリングされる- オブジェクト、配列、Map/Set などの参照を状態として持つ場合は、新しい値を作ってsetする必要がある
- previous value
- setHoge(hoge + 1) と setHoge(prev ⇒ prev + 1) の違い
- https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state
- react-scanでレンダリング表示を有効にする
<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> )}
- react-scanを有効にし、
App.tsx
のカウンターを更新したときと、<Counter />
コンポーネントを更新したときのレンダリングの違いを確認する <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> );};
- オブジェクトの値を直に書き換えてもコンポーネントが再レンダリングされないことを確認する。
onClick
で新規オブジェクトを作成してsetするとコンポーネントが再レンダリングされることを確認する
- previousValueを使って振る舞いの違いを確認する
- setCount(count+1)を2回呼んでも2ずつ増えないことを確認する
- previousValueを使って2ずつ増えるように変更する
useRef
- useStateと同じく値を保存しておくためのフック、ただし値を変更したときにコンポーネントが再レンダリングされない
- ほとんどの場合、DOMへアクセスするための参照を得るのに使う
- jsxのref属性として渡すと自動でDOMがref.currentでアクセスできるようになる
- useEffectとセットで使うことが多い
- createRef
- クラスコンポーネント、コンポーネント外、動的にrefを作りたい場合
- forwardRef
- React@18まで 自作コンポーネントにrefを渡すときに必要
- React@19以降 不要
useEffect
- 関数を覚えるフック
- 依存配列
- 覚えた関数を実行するタイミングが決まっている
- コンポーネントのレンダリング ← useStateその他のフック
- DOM updated (ref attached)
- DOM painting
(2回目以降) useEffect cleanup
useEffect
- 用途
- DOM操作
- ref.current?.focus()
- ref.current?.scrollIntoView()
- document.title = “new title”
- window.addEventListener(’resize’, () ⇒ console.log(window.clientWidth))
- httpリクエスト
- ブラウザーAPIの呼び出し
- DOM操作
- 戻り値で関数を返すと、4のuseEffect cleanupで実行される
- イベントハンドラの登録解除
- 深い話 https://overreacted.io/a-complete-guide-to-useeffect/
<Counter />
コンポーネントのボタンにrefを使ってフォーカスを当てる- ページ表示時にボタンまでスクロールするように変更する
- クリーンアップ関数の振る舞いを確認する
覚える値の種類と値の更新方法の組み合わせ
覚える値 | 更新方法 | |
---|---|---|
useState | プリミティブ、配列、オブジェクト | 関数呼び出し |
useRef | 任意の値(主にDOM) | 手動/自動 |
useEffect | 関数 | 依存配列の値の変更 |
サードパーティ製のカスタムフック
https://github.com/streamich/react-use
補足
useMemo / useCallback (& React.memo)
- React@17以降であれば、React Compiler導入で考慮不要
- https://react.dev/blog/2024/10/21/react-compiler-beta-release
以前の内容
- パフォーマンス最適化
- useMemoの用途
- 値のメモ化
- React.memoでメモ化した子コンポーネントのpropsに値を渡す場合
- 時間のかかる計算処理の結果を覚えておく
- 値のメモ化
- useCallbackの用途
- 関数のメモ化
- React.memoでメモ化した子コンポーネントのpropsに関数を渡す場合
- useEffectの依存配列に関数を渡したい場合
- 関数のメモ化
<Note />
をReact.memo()
を使ってメモ化し、再レンダリングされないことを確認する<Counter />
から<Note />
にconst now = Date.now()
を渡し、再レンダリングされるのを確認するuseMemo
を使い、依存配列がない場合、空の依存配列がある場合、別のステートで再レンダリングされた場合で違いを確認する。const handleClick = () => setState(counter + 1)
を<Note />
コンポーネントに渡し、再レンダリングされるのを確認する。useCallback
を使い、依存配列がない場合、空の依存配列がある場合、別のステートで再レンダリングされた場合で<Note/>
コンポーネントの再レンダリングを確認する。const handleClick = () => setState(counter + 1)
を prevValue を使って書き換え、依存配列が空の場合にも再レンダリングが起きないことを確認する。
useLayoutEffect
- 2と3の間で呼び出される
- コンポーネントのレンダリング ← useStateその他のフック
- DOM updated (ref attached)
(2回目以降) useLayoutEffect cleanup
useLayoutEffect
- DOM painting
- (2回目以降) useEffect cleanup
- useEffect
- DOM paintingをブロックするので時間のかかる処理は書かない
- useEffectでやると画面がちらつく処理など
- テーブルヘッダの幅変更
- ResizeObserver/MutationObserverの設定
useReducer
const [state, dispatch] = useReducer(reducer, initialState)
useState
よりも複雑な状態の管理に適している- 自分で定義する必要があるもの
- reducer関数
- 初期状態
- アクション
setState
ではなくdispatch(someAction)
で値を変更する<Counter />
コンポーネントをコピーして、<CounterWithReducer />
を作り、useState
をuseReducer
で置き換える
5. ページルーティング
- TanStack Router
- react-router
- wouter
- wouterをインストール
npm i wouter
App.tsx
の中身をsrc/pages/Index.tsx
として切り出す- 新しいページを作成する
src/pages/Todos.tsx
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
- redux toolkit
- zustand
- recoil
- jotai
- nanostate
TanStack Store
- TanStackシリーズ内部で使われているライブラリ
- セットアップ不要
- useStateと同じ使用感
- reselect、useSelectorといったメモ化処理が不要
-
インストール
npm install @tanstack/react-store -
src/state/counter.ts
ファイルを作成し、counterステートを定義するimport { Store } from "@tanstack/react-store";export const counterStore = new Store({counter: 0,}); -
<Counter />
コンポーネントをコピーして<CounterWithStore/>
コンポーネントを作り、useState
をuseStore
で置き換える。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"><buttonclassName="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>);}; -
Index.tsx
で<CounterWithStore />
をインポートする。 -
Todoルートに遷移後、Indexに戻ってくるとカウントが保持されていることを確認する。
-
src/state/counter.ts
でcounterStore
を直接エクスポートする代わりに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,]; -
src/state/counter.ts
の状態にmessage
とuseMessage
を追加する// ...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"]); -
<Greeting />
コンポーネントで message を表示する -
カウンターをクリックして数字を増やしても、
<Greeting />
が再描画されていないことを確認する。
7. サーバーステート
- TanStack Query (a.k.a react-query)
- 下準備:モックサーバーの準備
- json-serverをインストール
npm i -D json-server
db.json
ファイルを作成し、以下の内容を貼り付ける。
{ "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
http://localhost:3000
にアクセスしてサーバーが動いていることを確認
- 下準備:fetchの追加
src/api/todos.ts
ファイルを作成し、サーバーにリクエストを投げる関数を追加しておきます。
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();};
- tanstack-queryをインストール
npm i @tanstack/react-query @tanstack/react-query-devtools
- 公式からeslintのルールが提供されているので必要に応じて入れる
https://tanstack.com/query/latest/docs/eslint/eslint-plugin-query
<App />
コンポーネントの親要素にQueryClientProvider
を追加する
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';// ...
const client = new QueryClient();
function App() { return ( <QueryClientProvider client={client}> // ... </QueryClientProvider> );}
<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);
// ...}
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, });};
<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();
// ...
}
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 }), });};
- チェックボックスのオンオフでデータを更新する
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> );
補足
- QueryKeysとQueryFnはQueryOptionとしてあらかじめ定義できる
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. フォーム
- React Hook Form
- TanStack Form
-
react-hook-formをインストールする
npm i react-hook-form -
Todoを新規登録するためのAPI呼び出しを
src/api/todos
にusePostTodo
フックとして追加するexport const usePostTodo = () => {const queryClient = useQueryClient();return useMutation({mutationFn: post,onSuccess: () => queryClient.invalidateQueries({ queryKey }),});}; -
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>);}; -
pages/Todos.tsx
でTodoForm.tsx
をインポートする
入力値のチェック
register
のオプションを使うuseForm
のオプションにresolver
を渡す- https://react-hook-form.com/docs/useform#resolver
- zodなど外部のバリデーションライブラリを使う場合はこちら
9. 関連ライブラリ、SaaSなど
フレームワーク
- Next.js
- Remix
- Tanstack Start
- Astro
ドラッグアンドドロップ
- Pragmatic drag and drop
- FormKit’s Drag and Drop
- dnd-kit
- hello-pangea/dnd
認証
- SaaS
- Auth0
- AWS Cognito
- GCP Identity Platform
- Clerk
- ライブラリ
- Auth.js
- Better Auth
- Supertokens
- Keycloak
- Hanko
バリデーション
- ArkType
- zod
- valibot
- yup
className操作
- classnames
日付処理
- date-fns
- luxon
アイコン
- react-icons
グラフ描画
- Apache ECharts
- nivo
- Chart.js
- visx
- Recharts
metaタグ書き換え
- react-helmet-async
- React@19で標準機能になります
WiSYWIGエディタ
- Tiptap
- lexical
- Slate