序
ここ数か月、YOMINAというWebアプリを個人で開発しており、フレームワークとしてRemixを使いました。
結構How toベースでやってしまって、それでまあまあちゃんと動くRemixもすごいのですが、ちゃんと体系的に頭に入ってないし、使いこなせてる手ごたえがないので、まずはRemixにおけるHooksを、勉強を兼ねてまとめてみることにしました。
この記事ではアルファベット順でuseActionData
~useHref
まで書いてます。
前提
公式ドキュメントによると、Remixにはv2.10.3
現在、26のHooksがあります。
Remixは次期バージョンでReact-Router v7と統合されることになっていますが、破壊的変更はなさそうなので、このタイミングでまとめておくのは悪くないと思ってます。
- ルートモジュールの関数(
loader
/action
など)の詳細や、Remixが提供するユーティリティ関数、コンポーネントについては、立ち入りません - 26のHooksのうち、
unstable_
なHookが2つあります。これらは割愛します - 一応、公式ドキュメントの縮小再生産にならないように、自分なりの知見を入れたつもりです。内容の誤りや勘違いを見つけたら、編集リクエスト、コメントなどで教えていただけると幸いです
useActionData
useActionData
は、ルートモジュールでaction
関数からの戻り値を得るためのhookです。
useLoaderData
と並んで最頻出Hooksと言っていいでしょう。
ざっくりいうと、POST
の結果を取得するためのHookで、Form
送信後のバリデーション結果など、ユーザーアクションの結果をコンポーネントへと表示するときに有用です。
-
Form
でも、method="get"
になっている場合はGET
メソッドになるので、その場合はuseLoaderData
を使うことになります- 例えば、検索画面の
Form
をmethod="get"
にして、searchParams
から検索キーワードやソート順のパラメータを拾って検索、という実装にするケースなど
- 例えば、検索画面の
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// フォームのデータを取り出す
const userName = formData.get('userName')?.toString();
// バリデーション処理の例
if (!userName) {
return json({ status: 'failure', message: 'ユーザー名が未入力です' });
}
if (userName.length < 3) {
return json({
status: 'failure',
message: 'ユーザー名は3文字以上にしてください',
});
}
// 成功
return json({ status: 'success', message: `ようこそ, ${userName}さん` });
}
export default function UseActionData() {
// actionの戻り値を取得
const data = useActionData<typeof action>();
return (
<>
<h1>useActionData</h1>
<div className="card">
<Form method="post" className="flex flex-col gap-2">
<label>ユーザー名</label>
<input
className="border border-slate-500"
type="text"
name="userName"
/>
<input className="button" type="submit" />
</Form>
</div>
{/* actionの結果を表示 */}
{data && (
<div
className={`card ${data?.status === 'success' ? 'info' : 'error'}`}
>
<p>{data?.message}</p>
</div>
)}
</>
);
}
ポイント
-
action
がまだ実行されていない場合、useActionData
はundefined
を返します - 同じルートモジュール内の
action
の戻り値が得られます。ほかのルートモジュールからデータを取ってくることはできません - 型引数に
typeof action
を渡すと、戻り値の型はaction
関数の戻り値に沿った型になります - ただし、戻り値の型はシリアライズされており、例えば
Date
オブジェクトはuseActionData
を通すとstring
になります-
Single Fetchが有効な場合に、
action
関数から、json
関数を通さずに値を返すと、Date
やPromise
をもとのデータ型のままコンポーネントへと引き渡せるようになります - Single Fetchは
v2.10.3
ではまだunstable
扱いですが、今後要注意です
-
Single Fetchが有効な場合に、
useAsyncError
useAsyncError
はRemixコンポーネントの<Await>
とセットで使うもので、<Await>
が待っているPromise
がreject
されたときに、その内容を取得するHookです。
<Await>
のerrorElement
に渡されたコンポーネントが、失敗したPromise
のエラーの内容を知るために使います。
-
<Await>
はReactの<Suspense>
コンポーネントとセットで使います
import { Await, useAsyncError } from '@remix-run/react';
import { Suspense } from 'react';
export default function UseAsyncError() {
// rejectされるPromise
const promiseThatRejects = new Promise((_resolve, reject) => {
setTimeout(() => reject({ error: 'エラーが発生しました' }), 2000);
});
// resolveされるPromise
const promiseThatResolves = new Promise<{ message: string }>((resolve) => {
setTimeout(() => {
resolve({ message: '読み込みました' });
}, 2000);
});
// エラー時のパネル
const ErrorPanel = () => {
// ここでエラーの内容を取得する
const { error } = useAsyncError() as { error: string };
return (
<div className="card error">
<p>{error}</p>
</div>
);
};
const fallback = <div className="card">Loading...</div>;
return (
<>
<h2>UseAsyncError</h2>
<div className="card flex flex-col gap-2">
{/* 正常終了の例 */}
<Suspense fallback={fallback}>
<Await resolve={promiseThatResolves}>
{(result) => (
<div className="card info">
<p>{result.message}</p>
</div>
)}
</Await>
</Suspense>
{/* エラーの例 */}
<Suspense fallback={fallback}>
<Await resolve={promiseThatRejects} errorElement={<ErrorPanel />}>
必ずエラーになるので、この文章が表示されることはない
</Await>
</Suspense>
</div>
</>
);
}
ポイント
- これも、Single Fetchの影響を受けるhookの一つで、
loader
やaction
がPromise
を返せるようになると、サーバーで呼び出した非同期処理をコンポーネント側で<Await>
できるようになります- そうすると、例えば
loader
から呼び出している外部APIがエラーを返してきたとき、そのエラーメッセージを拾ってコンポーネントに表示する、ということができるようになります
- そうすると、例えば
useAsyncValue
useAsyncValue
はuseAsyncError
と同様、Remixコンポーネントの<Await>
とセットで使うもので、<Await>
が待っているPromise
がresolve
されたときに、その内容を取得するHookです。
<Await>
のchildren
たちが、Promise
の結果を知るために使います。
import { Await, useAsyncValue } from '@remix-run/react';
import { Suspense } from 'react';
export default function UseAsyncValue() {
// resolveされるPromise
const promiseThatResolves = new Promise<{ message: string }>((resolve) => {
setTimeout(() => {
resolve({ message: '読み込みました' });
}, 2000);
});
const ResultView = () => {
const result = useAsyncValue() as { message: string };
return (
<div className="card info">
<p>{result.message}</p>
</div>
);
};
const fallback = <div className="card">Loading...</div>;
return (
<>
<h2>UseAsyncValue</h2>
<div className="card flex flex-col gap-2">
<Suspense fallback={fallback}>
<Await resolve={promiseThatResolves}>
<ResultView />
</Await>
</Suspense>
</div>
</>
);
}
ポイント
-
useAsyncError
のサンプルコード内で比較用に書いた正常終了の例のように、このhookを使わなくてもPromise
の結果はコールバック関数の引数に入ってきます - コールバック関数との使い分けは難しいところで、正直あんまりピンときてません
- もしよい事例などお持ちの方がいたら、コメント等で教えてください
- サンプルコードでいう
<ResultView>
をコンポーネント化して使いまわしたいときとか?- でもコンポーネントにフレームワーク依存のHookをむやみに入れたくない、というお気持ち
useBeforeUnload
useBeforeUnload
はwindow.beforeunload
のラッパーであり、ページがアンロードされる前に特定の処理を実行するためのHookです。
中でevent.preventDefault()
を呼ぶことで警告メッセージを出したり、ユーザーの入力値をlocalStorage
に逃がしたりできます。
import { useBeforeUnload } from '@remix-run/react';
import { useState, useCallback, useEffect } from 'react';
export default function UseBeforeUnload() {
const [value, setValue] = useState('');
// 画面を閉じる前にlocalStorageに値を保存する
useBeforeUnload(
useCallback(
(event) => {
event.preventDefault();
localStorage.setItem('value', value);
},
[value]
)
);
// localStorageから値を復帰する
useEffect(() => {
if (value === '') {
const storageValue = localStorage.getItem('value');
if (storageValue) {
setValue(storageValue);
}
}
}, [value]);
return (
<>
<h2>UseBeforeUnload</h2>
<div className="card flex flex-col gap-2">
<input
className="border border-slate-500"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<p>{value}</p>
</div>
</>
);
}
ポイント
- このHook特有の話ではないですが、Remixはサーバーサイドレンダリングが基本なので、ブラウザ側のAPI(windowとかlocalStorageとか)にアクセスする処理は、
useEffect
に書かないとエラーになります
useBlocker
useBlocker
はナビゲーションにかかわるHookで、ブロック条件に合致する状態の時に、ページ移動をブロックします。
特に長時間の編集作業を伴うようなアプリケーションで、ユーザーの意図しないリンクのクリックにより、編集中の内容が失われたりしないよう制御するケースなどに使えます。
import { useBlocker, Form, Link } from '@remix-run/react';
import { useState } from 'react';
export default function UseBlocker() {
// フォームに変更があるかどうか
const [isDirty, setIsDirty] = useState(false);
const blocker = useBlocker(
// ブロックする条件を書く
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
const handleChange = () => {
setIsDirty(true);
};
const handleSubmit = () => {
setIsDirty(false);
};
return (
<>
{/* ブロックされていたときだけレンダリング */}
{blocker.state === 'blocked' && (
<div className="card">
<p>変更が保存されていません。本当に移動しますか?</p>
<div className="flex flex-row justify-end gap-2">
<button className="button" onClick={() => blocker.proceed()}>
はい
</button>
<button className="button" onClick={() => blocker.reset()}>
いいえ
</button>
</div>
</div>
)}
<div className="card">
<Form method="post" onSubmit={handleSubmit}>
<textarea onChange={handleChange} />
</Form>
</div>
<div className="card info">
{/* Linkタグだとブロックが機能する */}
<Link to="/">Linkタグ</Link>
</div>
<div className="card error">
{/* aタグだとブロックは機能しない */}
<a href="/">aタグ</a>
</div>
</>
);
}
ポイント
- このHookがブロックするのはRemixのルーティングを通して移動する場合に限ります
-
<Link>
コンポーネントを使っている場合はブロックされますが、<a>
タグのリンクはRemixのルーティングを使わないので、ブロックできません - 当然ですが、
action
からのredirect
とかもRemixのルーティングを使わないサーバーサイドの処理なのでダメです
-
- 公式サイトに以下の記述があります。要所要所に目的をもって使うようにしたいですね
Blocking a user from navigating is a bit of an anti-pattern, so please carefully consider any usage of this hook and use it sparingly. In the de-facto use case of preventing a user navigating away from a half-filled form, you might consider persisting unsaved state to
sessionStorage
and automatically re-filling it if they return instead of blocking them from navigating away.
拙訳
ユーザーのページ移動をブロックすることはややアンチパターンですから、このhookの利用は慎重に検討し、控えめにしてください。
途中までフォームを入力した状態でページ移動してしまうことを防ぐという事実上のユースケースにおいては、ページ移動をブロックする代わりに、未保存の内容をsessionStorage
に保存し、ユーザーが戻ってきたときに自動的に再入力する方法を検討してください。
useFetcher
useFetcher
はページ遷移無しでサーバーと通信するためのHookです。
Remixはルートモジュールの中でloader
⇒Component
⇒action
というデータフローに沿ってアプリを動かしていきますが、useFetcherを使うと、ほかのルートモジュールのloader
を使ってデータを読んだり、fetcher.Form
のPOST
を通じて、ほかのルートモジュールのaction
を呼んだりできます。
import { useFetcher } from '@remix-run/react';
import { useEffect } from 'react';
import type { loader, action } from './useFetcher.api';
type LoaderData = Awaited<typeof loader>;
type ActionData = Awaited<typeof action>;
// お気に入りボタン
const FavoriteButton = () => {
const fetcher = useFetcher<LoaderData | ActionData>({ key: 'favorite' });
// 初回ロード
useEffect(() => {
fetcher.load('/useFetcher/api');
}, []);
// データを取り出す
// fetcher.dataは最初undefined、
// load()の後はloaderの戻り値、
// submitの後はactionの戻り値が入ってきます。
const isFavorite = fetcher.data ? fetcher.data.favorite : undefined;
return fetcher.data ? (
<fetcher.Form className="button" method="post" action="/useFetcher/api">
<button
type="submit"
name="favorite"
value={isFavorite ? 'false' : 'true'}
>
{isFavorite ? '★ お気に入り解除' : '☆ お気に入りに追加'}
</button>
</fetcher.Form>
) : (
<p>読み込み中</p>
);
};
// お気に入り状態表示コンポーネント
const FavoriteDisplay = () => {
// 同じキーで取得
// * キーを設定しない場合、この2つのfetcherは別のものになる
const fetcher = useFetcher<LoaderData | ActionData>({ key: 'favorite' });
const isFavorite = fetcher.data ? fetcher.data.favorite : undefined;
return (
<div className="card">
{isFavorite ? 'お気に入り' : 'お気に入りじゃない'}
</div>
);
};
export default function UseFetcher() {
return (
<>
<FavoriteButton />
<FavoriteDisplay />
</>
);
}
import { json, type ActionFunctionArgs } from '@remix-run/node';
// サンプルなのでモジュールスコープで保存する
let favorite = false;
// POST useFetcher/api
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
favorite = formData.get('favorite') === 'true';
return json({ favorite });
}
// GET useFetcher/api
export async function loader() {
return json({ favorite });
}
ポイント
-
key
を指定することで、同じfetcherを複数のコンポーネントで共有することができます -
fetcher.load()
は読み込みが終わるとコンポーネントの再レンダリングを走らせます- というのは微妙な表現で、
react-router
のソースを見ると、useMemo
の依存配列にdata
が入ってますので、より正確にはfetcher.load()
した結果、fetcher.data
が変更されたときに再レンダリングされる、ということみたいです- これは結構気を付けないといけない部分な気がする
- というのは微妙な表現で、
-
fetcher.data
は初期値undefined
、fetcher.load()
の後はloader
の戻り値、submit
後はaction
の戻り値が入ってきます- なので、ルートモジュールのloader⇒Component⇒actionの流れと一緒ですね
-
fetcher.state
はidle
、submitting
(post処理中)、loading
(get処理中)の3種類で、そのfetcherが今なにをやってるのかがわかります
useFetchers
useFetchers
は、アプリケーション全体でin-flightなfetcherの配列を取得します。
-
useFetchers
で取得したfetcherにはForm
、submit
、load
は含まれておらず、formData
やstate
などの状態のみが取得できます
import { useFetcher, useFetchers } from '@remix-run/react';
import { useEffect } from 'react';
import type { loader, action } from './useFetchers.api';
type LoaderData = Awaited<typeof loader>;
type ActionData = Awaited<typeof action>;
const ENDPOINT = '/useFetchers/api';
// カウンター
const Counter = (props: { id: string; initValue: number }) => {
const fetcher = useFetcher<LoaderData | ActionData>({
key: `counter-${props.id}`,
});
// 初回ロード
useEffect(() => {
fetcher.load(`${ENDPOINT}?id=${props.id}`);
}, []);
// データを取得
const value = fetcher.data?.value ?? props.initValue;
return (
<fetcher.Form
className="flex flex-row gap-2"
method="post"
action={ENDPOINT}
>
<input type="hidden" name="id" value={props.id} />
<label>現在値:{value}</label>
<button className="button" type="submit" name="value" value={value + 1}>
+
</button>
</fetcher.Form>
);
};
// fetcher表示コンポーネント
const Counters = () => {
const fetchers = useFetchers();
return (
<div className="card flex flex-rowgap-2">
<p>処理中のカウンター:</p>
{fetchers.length === 0 ? (
<p>なし</p>
) : (
fetchers.map((fetcher) => <p key={fetcher.key}>{fetcher.key}</p>)
)}
</div>
);
};
export default function UseFetchers() {
return (
<>
<Counter id="1" initValue={0} />
<Counter id="2" initValue={1} />
<Counter id="3" initValue={2} />
<Counters />
</>
);
}
import {
json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from '@remix-run/node';
// サンプルなのでモジュールスコープで保存する
let data = new Map<string, number>();
// POST useFetchers/api
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get('id')?.toString();
const value = Number(formData.get('value'));
// 疑似的にカウントに0.5秒かける
await new Promise((res) => setTimeout(res, 500));
if (id) {
data.set(id, value);
return json({ value });
}
return json({ value: NaN, error: 'id not found.' });
}
// GET useFetchers/api
export async function loader(args: LoaderFunctionArgs) {
const url = new URL(args.request.url);
const id = url.searchParams.get('id');
if (id) {
return json({ value: data.get(id) });
}
return json({ value: NaN, error: 'id not found.' });
}
ポイント
-
in-flight
ってなんすかという話なんですが、fetcher.state === "idle"
のfetcherは入ってこないようなので、そういうことだと思います- このサンプルでは
action
の処理に500msの遅延を入れてますが、その処理中(fetcher.state === "submitting"
になっているとき)だけ、<Counters>
にfetcher.key
が表示されます
- このサンプルでは
useFormAction
useFormAction
は、<Form>
のaction
要素に指定する、フォーム送信先のURLを作るためのhookです。現在のパスから最も近いルートや、そこからの相対パスで送信先を指定できます。
import { useFormAction, useActionData, Form } from '@remix-run/react';
export async function action() {
return { message: 'route action called.' };
}
const FormAction = (props: { route?: string }) => {
const action = useFormAction(props.route);
return (
<Form className="card flex flex-row gap-2" method="post" action={action}>
<label>{action}</label>
<button type="submit" className="button">
送信
</button>
</Form>
);
};
export default function UseFormAction() {
const data = useActionData<{ message: string }>();
return (
<>
{/* この時、結果をuseActionDataで取得できるのはルートモジュールのactionが呼ばれたときだけ */}
{data && <div>{data.message}</div>}
<FormAction /> {/* これは/useActionDataにpost */}
<FormAction route="add" /> {/* これは/useActionData/addにpost */}
<FormAction route="delete" /> {/* これは/useActionData/deleteにpost */}
</>
);
}
export async function action() {
console.log('add action called.');
return null;
}
export async function action() {
console.log('delete action called.');
return null;
}
ポイント
- 個人的には 「APIのPOSTエンドポイントを相対パスで指定しないといけない」というシチュエーションがどうも想像しにくくて、使い所のイメージがいまいち掴めてません
-
/user/$id/draft/$articleId/edit
と/user/$id/article/$articleId/edit
にuser.$id.draft.$articleId.tsx
とuser.$id.article.$articleId.tsx
から送る必要があって、そこで使うフォームを共通化する、とか?
-
useHref
useHref
はuseFormAction
と似たHookで、<a>
タグなどのhref
要素に渡す絶対パス文字列を作るものです。
import { Link, useHref } from '@remix-run/react';
export const RouteHref = (props: { to: string }) => {
const href = useHref(props.to);
return <p>{href}</p>;
};
export const PathHref = (props: { to: string }) => {
const href = useHref(props.to, { relative: 'path' });
return <p>{href}</p>;
};
export default function UseHref() {
return (
<>
<div className="card">
<RouteHref to="" />
<PathHref to="" />
<RouteHref to="some/where" />
<PathHref to="some/where" />
<RouteHref to="../other" />
<PathHref to="../other" />
</div>
<div className="card">
<Link to="child">Go to child</Link>
</div>
</>
);
}
import { RouteHref, PathHref } from './useHref._index';
export default function UseHrefChild() {
return (
<div className="card">
<RouteHref to="" />
<PathHref to="" />
<RouteHref to="some/where" />
<PathHref to="some/where" />
<RouteHref to="../other" />
<PathHref to="../other" />
</div>
);
}
ポイント
- これも用途がいまいちピンと来ないHook
- というのも、
<Link>
コンポーネントはその処理の中でuseHrefを呼んでいるので、Link
コンポーネントでページナビゲーションしている分には、わざわざこれを単独で使う必要がないです- 相対パスから絶対パスに変換したくて、ナビゲーション目的じゃなくて(
<Link>
コンポーネントが使えない)、しかしRemixのルーティングに準拠しURLが欲しいときに使う、という感じだと思うのですが、やはりあまりピンと来ない…
- 相対パスから絶対パスに変換したくて、ナビゲーション目的じゃなくて(
終わりに
useFetcher
は色々試し甲斐がありそうです。
YOMINAでも、useFetcher
をちゃんと使えていたら、もっときれいな実装になったなぁ、という部分がいくつかあって、調べた甲斐がありました。
やっぱり公式ドキュメントは勉強になりますね…。
ここまでのサンプルコードを動かせるStackblitzを置いておきます。
useLoaderData
から先はまた別の記事に、近日中に書こうと思います。