React19を使ってTodoアプリを開発する(バックエンドはGoのAPI)

この開発について
以前開発したGoのREST APIをReactで叩く感じで、Todo アプリを完成させてみる。
使用技術を決める
- React19
- Vite(環境構築)
- node.js (jsランタイム)
- Typescript
- TailwindCSS
- shadcn/ui (コンポーネント)
- TanStack Query(データフェッチ)
- Tanstack Router (ルーティング)
- Zod (バリデーション)
- Biome (Lint+Format)
- Vitest (テスト)
新めの、使ってみたい技術ベースで決めた。

環境構築
プロジェクト作成
以下を参考に、
- プロジェクト作成
- テストツールインストール
- パスエイリアス設定
https://zenn.dev/kazukix/articles/react-setup-2024
Biome
Tailwind
viteの設定があった。
shadcn/ui
tailwind4で動くカナリーverのものをインストールしてみた。
パスエイリアスの再設定(要求される)等々が必要だった。

プロジェクト構成について
以下の二つを軸に。
- The Feature Based Pattern
- Presentational/Container Pattern
とりあえず以下の感じでやってみる。
Tanstack routerを使うので、File Basedルーティングになる。(/routes
配下?)
src
├── assets
├── features
│ ├── auth
│ ├── task
│ └── user
│ ├── shared
│ │ ├── components
│ │ ├── containers
│ │ └── hooks
│ ├── user-detail
│ └── user-list
├── lib
├── routes
├── shared
│ ├── components
│ └── hooks
└── styles
参考

Reactのデザインパターン
プロジェクト構成を考えるに際して、Reactデザインパターンが気になる。
フックパターン
React Hooks
クラスコンポーネントを使わずにステートやライフサイクルメソッドを利用できる。
- ステートフック
関数コンポーネント内のステートを管理する。
- 副作用フック
コンポーネントのライフサイクルに接続する(hook into)ことができる
カスタムHook
実態としては、関数コンポーネントから呼び出し可能な関数
ルールとして、
-
useXxxx
であること - 関数内では他のフックを利用していること
- コンポーネントで再利用したい値/コードを返り値として返すこと
フックを使用することで、コンポーネントのロジックをいくつかの小さなまとまりへ分割できる
(クラスコンポーネントと比べるとわかる)
HOC(高階コンポーネント)パターン
- 高階関数 :「関数を引数にとる」「関数を返す」のいずれかまたはどちらも満たすような関数のこと
高階コンポーネントパターンは、高階関数を利用して、アプリケーション全体で再利用可能なロジックをコンポーネントに渡す。
つまり、高階コンポーネント(HOCs)はコンポーネントを引数にとり、さらに手の加えられた新たなコンポーネントを返す。
-
HOCs はアプリケーションを通して横断的な関心ごとにまとめて対応するとき特に有効
- コンポーネント共通のホバー機能をつける
- などなど
-
同じロジックを複数のコンポーネントで使う
- 認可
- スタイル
- グローバルステート
同じスタイルを当てられるHOC。
function withStyles(Component) {
return props => {
const style = { padding: '0.2rem', margin: '1rem' }
return <Component style={style} {...props} />
}
}
const Button = () = <button>Click me!</button>
const Text = () => <p>Hello World!</p>
const StyledButton = withStyles(Button)
const StyedText = withStyles(Text)
- 複数のHOCを合成(compose)することも可能。
フックで置き換えることも可能
フックによって、コンポーネントツリーの深さを減らすことができる。
-
Pros
再利用したいロジックが一箇所にまとまる
関心の分離
DRY -
Cons
HOCに渡すpropsの名前が、衝突する可能性がある。
function withStyles(Component) {
return props => {
const style = {
padding: '0.2rem',
margin: '1rem',
...props.style
}
return <Component style={style} {...props} />
}
}
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)
Presentational/Containerパターン
コンポーネントの役割を、PresentationとContainerで分割
Container Component
データを管理する。ラップしているPresentational Componentにデータを渡す。
Presentational Component
Container Componentから受け取ったデータを期待通りに表示する。データを変更しない。
- Reactで関心の分離を図る
- ビューをロジックから分離する
Compound Component パターン
1つのタスクを実行するために連携する複数のコンポーネントを作成するのに有用。
-
useContext
を使って、配下のコンポーネントでステートを共有できるようにする。
複合コンポーネントのプロパティとして設定する。
▶︎ FlyOut
コンポーネントのみのインポートですむ
※子要素としてレンダリングするでも可能
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
Render Propsパターン
レンダープロップス=JSX(TSX)を返却する関数を値とするprops
▶︎ props を用いて何をレンダリングするかを外側から決定できるような構造に
- コンポーネント自身は、レンダープロップ以外のものをレンダリングしない
Input
コンポーネントはレンダープロップを受け取る。
function Input(props) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.render(value)}
</>
);
}
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input
render={value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
/>
</div>
);
}
これらも、レンダリングしたいものを注入する形になるので、ロジックとUIを引き剥がせる。
関心の分離も可能。
propとしてではなく、childrenとして渡すのもある。
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input>
{value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
</Input>
</div>
);
}
function Input(props) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.children(value)}
</>
);
}
- 多くの場合、フックで置き換えられる。
- render propにはライフサイクルメソッドを追加できないので、受け取ったデータを変更する必要のないコンポーネントに対してのみ使用可能

以下の記事によれば、
HOCよりもレンダープロップの方が良さげ。
- 孫のコンポーネントに、PropsやStateがどのコンポーネントから渡されたのかがわかりやすい
- 名前の衝突が起こりづらい
- 型定義が簡単
- HOCはJSXの中で動的に使えず、コンポーネントの外で結合させなければならない

Tanstack Routerを理解したい。
ファイル名
1. __root.tsx
全てのルートに適用される。つまり、このコンポーネントは常にレンダリングされる。
2. $token
(ex:tasks/$id
)
$
をprefixに使用すると、URL Pathnameとなる。
マッチしたpathnameはloader
関数やコンポーネントの中で参照できる。(loader
はまた調べる。)
3. _
prefix
子要素のレイアウトルートとなる。が、いまいちよく分からん。
4. route.tsx
ディレクトリパス、ディレクトリ配下のパスでレンダリングされる。
src/routes/users/route.tsx
内でOutlet
を載せておけば、src/routes/users/index.tsx
にも適用される。
特定のディレクトリにおけるレイアウトとして扱えそう。
「static route」としてのroute.tsx
は、pathの先頭がマッチしたら適用される。
(/a
だけでなく/a/b
もマッチ)
5. index.tsx
pathが正確に一致しないと適用されない。
つまり、レイアウトとして振る舞えない。
ページコンポーネントとして扱えると思う。
RouteOptions
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
component, // skip
loader,
errorComponent,
pendingComponent,
validateSearch,
})
1. loader
ルートが呼ばれるタイミングで発火する。失敗時にはエラーをthrow。
type loader = ({/** 省略 */}) => Promise<TLoaderData> | TLoaderData | void
loaderがPromiseを返している間はpending状態。
rejectされるとエラー状態に。
2. errorComponent``pendingComponent
各ルートごとに適用できる。loaderでかえすPromiseの状態によって描画するコンポーネントを指定可能。
3. validateSerch
serch Paramsを検証可能に。
zodのようなライブラリと組み合わせれる。
Code Splitting(Lazy Loading)
目的は、
- 初回ページ読み込み時に必要なコード量を減らす
- 対象コードが必要になったときに読み込む
- チャンク分割することで細かい単位でキャッシュ可能に
方法は、
.lazy.tsx
suffixをつけて、createLazyFileRoute
を利用する
Critical Route(初回にロードされるコード)
初回ロードされるモノ
Path Parsing/Serialization
Search Param Validation
Loaders, Before Load
Route Context
Static Data
Links
Scripts
Styles
All other route configuration not listed below
Lazy Route (必要になったときに遅延してロードされるコード)
各ルートに対応するページのコンポーネントは、(index.tsx
等々の)Lazyらしい。
遅延ロードされるモノ
Route Component
Error Component
Pending Component
Not-found Component
Search Params
- Tanstack RouterのSearch Paramsは、型安全・バリデーション・JSON構造の扱いが可能
- tanstack routerだと、Search Params状態管理のように扱える。
const link = (
<Link
to="/shop"
search={{
pageIndex: 3,
includeCategories: ["electronics", "gifts"],
sortBy: "price",
desc: true,
}}
/>
);
- 型として扱うため、バリデーション(
validateSearch
)可能
type ProductSearchSortOptions = "newest" | "oldest" | "price";
type ProductSearch = {
page: number;
filter: string;
sort: ProductSearchSortOptions;
};
export const Route = createFileRoute("/shop/products")({
validateSearch: (search: Record<string, unknown>): ProductSearch => {
return {
page: Number(search?.page ?? 1), // `page` は `number`
filter: (search.filter as string) || "", // `filter` は `string`
sort: (search.sort as ProductSearchSortOptions) || "newest", // `sort` は `ProductSearchSortOptions`
};
},
});
キャッシュを備えたData Loading
- データをpreload氏うてキャッシュしたデータを表示したり、以前取得したデータをキャッシュ&再利用が可能
Dependency-based Stale-While-Revalidate Caching
キャッシュはルートのdependencies&ルートのパス名によって制御。
RunRouteOptions
の一つである、
loaderDeps: ({ search: { index, size } }) => ({ index, size })
-
loader 内では search params (?key=value の部分) を直接参照できない(仕様上)
Tanstack では、「URL の pathname(パス)」+「loaderDeps(依存関係)」 をキャッシュのキーとしている。
もし loader 内で search params を直接使ってしまうと、TanStack Router は 「/users/user」と「/users/user?userId=1」を同じものとしてキャッシュしてしまう 可能性がある。 -
loaderDepsを使い、search Paramsを明示的にloaderに渡すと正しくキャッシュ管理ができる
export const Route = createFileRoute('/users/user')({
// `validateSearch` で search params を型安全に扱う
validateSearch: (search) =>
search as {
userId: string
},
// `loaderDeps` を使って search params を明示的に渡す
loaderDeps: ({ search: { userId } }) => ({
userId,
}),
// `deps` を通して search params を loader に渡す
loader: async ({ deps: { userId } }) => getUser(userId),
});
Tanstack QueryのSuspense(番外)
Suspenseに対応したQueryと、File-Basedなアプローチととても相性がいい。
RouterとQueryを組み合わせる
import { useSuspenseQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/fetchPosts';
export function Posts() {
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
return (
<div>
<h2 className="text-xl font-bold">Posts</h2>
<ul>
{posts.map((post) => (
<li key={post.id} className="border-b py-2">
<h3 className="font-semibold">{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
</div>
);
}
import { createFileRoute } from '@tanstack/react-router';
import { Posts } from '../components/Posts';
export const Route = createFileRoute('/posts')({
component: Posts,
errorComponent: () => <div className="text-red-500">Something went wrong!</div>,
pendingComponent: () => <div className="text-gray-500">Loading posts...</div>,
});
※ Queryだけの場合
function Todos() {
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<div>
{data.map((todo) => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
);
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ErrorBoundary fallback={<div>Oh no!</div>}>
<Todos />
</ErrorBoundary>
</Suspense>
);
}
RouterのloaderとuseSuspenseQueryでキャッシュデータの状態を管理
loaderでデータを事前に用意(preload)して、Tanstack Queryのキャッシュに保存する。
▶︎ useSuspenseQueryが呼ばれるタイミングでデータが確保された状態に。
// src/routes/posts.tsx
const postsQueryOptions = queryOptions({
queryKey: 'posts',
queryFn: () => fetchPosts,
})
export const Route = createFileRoute('/posts')({
// Use the `loader` option to ensure that the data is loaded
loader: () => queryClient.ensureQueryData(postsQueryOptions),
component: () => {
// Read the data from the cache and subscribe to updates
const posts = useSuspenseQuery(postsQueryOptions)
return (
<div>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</div>
)
},
})
また、発生したerrorはルートのerrorComponentが、pending状態はルートのpendingComponentが処理する。

ちょっと適当にfetchする関数を定義して、Suspenseも確かめてみる。
後からTanstack Queryに転換したいけど、一旦適当に書いてみる。
コード
import type { HealthCheck } from "../types/type";
export async function fetchHealthCheck(): Promise<HealthCheck> {
await new Promise((resolve) => {
setTimeout(resolve, 3000);
});
const response = await fetch("http://localhost:8080/health");
if (!response.ok) {
throw new Error("Error");
}
const result: HealthCheck = await response.json();
return result;
}
import { use } from "react";
import { fetchHealthCheck } from "../api/fetch-api";
import HealthCheckMessage from "../components/HealthCheck";
import type { HealthCheck } from "../types/type";
// useはレンダリングフェーズにおいて呼ばれるPromiseには対応していないので
// レンダリング前にPromiseを用意している
const healthCheckPromise = fetchHealthCheck();
function HealhCheckContainer() {
const result = use<HealthCheck>(healthCheckPromise);
const data = result.data;
const message = data.health_check;
return (
<>
<HealthCheckMessage message={message} />
</>
);
}
export default HealhCheckContainer;
import HealhCheckContainer from "@/features/health/containers/HealhCheckContainer";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/health/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<>
<Suspense fallback={<p>loading...</p>}>
<HealhCheckContainer />
</Suspense>
</>
);
}
React19により導入されたuseを使ってみた。
useは、Promiseから中身を取り出すのが役目であり、未解決の場合はサスペンドさせる。
▶︎こいつのおかげで、Suspenseは使いやすくなった模様。
ただ、クライアントコンポーネント内では、async/await
がサポートされていない。
▶︎ レンダリングフェーズで非同期関数を呼び出せない
- 意図せず何度も非同期関数が呼ばれる可能性
プロミスはキャッシュされるべき....じゃないとレンダリングのたびに非同期処理が走る
しかし、useを使うのにその機能は用意されていない。
よって、サスペンス対応の外部ライブラリを使用するべき。
また、データ取得する例はドキュメントにもないそう。

Tanstack Router & Queryを理解したい...
- データフェッチではなく、効率的な非同期状態の管理が目的。
useQuery
(データ取得)
queryKeyとqueryFnオプションにキャッシュ識別のためのkeyとデータフェッチのための関数を渡すだけ
const { data, isPending } = useQuery({
queryKey: ["issues"],
queryFn: () => axios.get("/issues").then(res => res.data)
})
オプション
queryKey
- 必須のオプション
- キャッシュを識別する
queryFn
- Promiseを返す関数
queryFn: (context: QueryFunctionContext) => Promise<TData>
- QueryFunctionContext型のcontextを参照できる
- queryKey
- queryKeyに渡した値がcontextを通じて参照かのう
- signal
- クエリのキャンセルが可能
- meta
- queryKey
enabled
クエリ同士に依存関係を持たせることができる。
enabledオプションがfalseの場合はクエリが実行されない。
以下の場合だと、ユーザー取得リクエストが完了するまで、プロジェクト取得のリクエストが走らない。
// ユーザ情報を取得
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
const userId = user?.id
const {
status,
fetchStatus,
data: projects,
} = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
// ユーザIDの取得が完了されるまでクエリは発火されない
enabled: !!userId,
})
ただ、waterfallという問題には気をつける。
staleTime
キャッシュをstaleにするまでの時間。
- stale状態:キャッシュされたデータが「古い」と見なされる状態
デフォルトは0。
Infinityにすると、自動的にstaleにはならず、常にfleshとして扱われる。
staleTime: number | Infinity
gcTime
使われなくなったキャッシュのデータをGC(ガベージコレクション)するまでの時間
select
queryFnで取得したデータを加工する。
最終的には、selectで変換された値が返る
const transformTodoNames = (data: Todos) =>
data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: transformTodoNames,
})
返り値
const {
data, // データ
error,
status, // pending | success | error
fetchStatus,
isPending,
isSuccess,
isFetching,
isPaused,
isError,
isLoading
} = useQuery()
- dataとstatus(データに関する状態)
- isPending,isSuccess,isErrorはstatusの状態を確かめるもの
function Todos() {
const { status, data, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
// if (isPending) {
if (status === 'pending') {
return <span>Loading...</span>
}
// if (isError) {
if (status === 'error') {
return <span>Error: {error.message}</span>
}
// also status === 'success', but "else" logic works, too
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
-
fetchStatus(queryFnの実行に関する状態)
- pause : クエリを実行しようとしているが一時停止している状態
- idle : クエリが実行されていない
- fetching : クエリ実行中
-
isLoading : isFetching && isPendingな状態
- isPendingは、キャッシュされたデータがなく、クエリの実行が完了していない状態
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/c22ed5#isloadingとispendingの違い
- isPendingは、キャッシュされたデータがなく、クエリの実行が完了していない状態
useSuspenseQuery
useMutation(データ更新)
Delete/Post/Patch処理を担当。
useQueryが宣言的なのに

認証
バックエンドAPI
http://localhost:8080/login
すると、JWTが返ってくる。
{
"data": {
"jwt_token": "string"
},
"status": 0
}
フロント(React)でどう認証状態を扱うか
- ログインながれ
- ログインフォームを送信する
- バックエンドからレスポンスが返る
- cookieにトークンを保存する
- ログアウト流れ
- ログアウトボタンを押す(API叩く)
- cookieからも削除
コンテキストで管理?
何を「認証状態(ログイン状態)」とするか。
-
cookieにjwtがある&&users/meでカレントユーザーが取得できる
-
AuthProvider
コンポーネント -
cookieからJWTを取り出し、期限を確認する
- そしてそもそもcookieになかったらだめ
-
トークンを元にユーザ情報を取得するフックを呼び出し、コンテキストに注入する
- ユーザー情報はキャッシュしておく
まとめると、「cookieに値がある」&「users/meで200(status)が返ってくる」と認証状態とみなす。
トークンを解析して、有効期限を見る、とかでもいいけど、
- APIはログイン状態を管理していて、ログアウト(Redisにあるorない)している場合を知るためには、APIに聞くしかない。
CORSではまった!
Authorizationヘッダーをつけたらプリフライトリクエストが発生する。
ここら辺は記事のネタとしてまた調べ直す。
条件 | プリフライトリクエスト発生? |
---|---|
Authorization ヘッダーあり | ✅ 発生 |
Content-Type: application/json | ✅ 発生 |
credentials: "include" あり | ⚠️ CORS設定次第 |
GET / POST / HEAD かつ Content-Type: text/plain など | ❌ 発生しない |
キャッシュされてるけどリロードしたらまたフェッチするなぁ
AuthProvider内で、クエリ叩いてる。けど、リロードすると再フェッチ。
これは当たり前???
そこら辺をまた調べる。
function AppProvider({ children }: AppProviderProps) {
return (
<>
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<p>error</p>}>
<Suspense fallback={<LoadingSpinner />}>
<AuthProvider>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{children}
</ThemeProvider>
</AuthProvider>
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</>
);
}
- どうやら、キャッシュされる場所はメモリであり、(考えてみればそりゃそう?)ページがリフレッシュされると当たり前に再フェッチを行う。
以下が使える?
一部のクエリにだけ、これを適用させるには....。が課題。

persisterを使ってローカルストレージにキャッシュを永続化
これで、ローカルストレージやセッションストア等にデータをキャッシュできる。
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
});
persistQueryClient({
queryClient,
persister: createSyncStoragePersister({ storage: window.localStorage }),
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const keys = query.queryKey.map((key) => {
return String(key);
});
const allowKeys = userKeys.current();
return (
keys.length === allowKeys.length &&
keys.every((key) => allowKeys.includes(key))
);
},
},
});
function AppProvider({ children }: AppProviderProps) {
return (
<>
<QueryClientProvider client={queryClient}>
....

なんとなくライブラリ等の作り方がわかった時点で、一旦モチベが落ち着いた。
また気が向いたらやる。