Remix v2のHooks全部使う(useActionData～useHref編)

# 序
ここ数か月、YOMINAというWebアプリを個人で開発しており、フレームワークとして[Remix](https://remix.run/)を使いました。

https://yomina.app

https://qiita.com/kedama-t/items/1e93352ac2f7e3851001

結構How toベースでやってしまって、それでまあまあちゃんと動くRemixもすごいのですが、ちゃんと体系的に頭に入ってないし、使いこなせてる手ごたえがないので、まずはRemixにおけるHooksを、勉強を兼ねてまとめてみることにしました。

この記事ではアルファベット順で`useActionData`～`useHref`まで書いてます。

## 前提

[公式ドキュメントによると](https://remix.run/docs/en/main/hooks/use-action-data)、Remixには`v2.10.3`現在、26のHooksがあります。

Remixは次期バージョンで[React-Router v7と統合される](https://remix.run/blog/merging-remix-and-react-router)ことになっていますが、破壊的変更はなさそうなので、このタイミングでまとめておくのは悪くないと思ってます。

- ルートモジュールの関数(`loader`/`action`など)の詳細や、Remixが提供するユーティリティ関数、コンポーネントについては、立ち入りません
- 26のHooksのうち、`unstable_`なHookが2つあります。これらは割愛します
- 一応、公式ドキュメントの縮小再生産にならないように、自分なりの知見を入れたつもりです。内容の誤りや勘違いを見つけたら、編集リクエスト、コメントなどで教えていただけると幸いです

## [useActionData](https://remix.run/docs/en/main/hooks/use-action-data)

`useActionData`は、ルートモジュールで[`action`関数](https://remix.run/docs/en/main/route/action)からの戻り値を得るためのhookです。
`useLoaderData`と並んで最頻出Hooksと言っていいでしょう。

ざっくりいうと、`POST`の結果を取得するためのHookで、`Form`送信後のバリデーション結果など、ユーザーアクションの結果をコンポーネントへと表示するときに有用です。
- `Form`でも、`method="get"`になっている場合は`GET`メソッドになるので、その場合は`useLoaderData`を使うことになります
    - 例えば、検索画面の`Form`を`method="get"`にして、`searchParams`から検索キーワードやソート順のパラメータを拾って検索、という実装にするケースなど

```tsx:useActionData.tsx
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](https://remix.run/docs/en/main/guides/single-fetch)が有効な場合に、`action`関数から、`json`関数を通さずに値を返すと、`Date`や`Promise`をもとのデータ型のままコンポーネントへと引き渡せるようになります
    - Single Fetchは`v2.10.3`ではまだ`unstable`扱いですが、今後要注意です

## [useAsyncError](https://remix.run/docs/en/main/hooks/use-async-error)

`useAsyncError`はRemixコンポーネントの[`<Await>`](https://remix.run/docs/en/main/components/await)とセットで使うもので、`<Await>`が待っている`Promise`が`reject`されたときに、その内容を取得するHookです。
`<Await>`の`errorElement`に渡されたコンポーネントが、失敗した`Promise`のエラーの内容を知るために使います。

- `<Await>`はReactの[`<Suspense>`](https://ja.react.dev/reference/react/Suspense)コンポーネントとセットで使います

```tsx:useAsyncError.tsx
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](https://remix.run/docs/en/main/hooks/use-async-value)

`useAsyncValue`は`useAsyncError`と同様、Remixコンポーネントの[`<Await>`](https://remix.run/docs/en/main/components/await)とセットで使うもので、`<Await>`が待っている`Promise`が`resolve`されたときに、その内容を取得するHookです。
`<Await>`の`children`たちが、`Promise`の結果を知るために使います。

```tsx:useAsyncValue.tsx
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](https://remix.run/docs/en/main/hooks/use-before-unload)

`useBeforeUnload`は`window.beforeunload`のラッパーであり、ページがアンロードされる前に特定の処理を実行するためのHookです。
中で`event.preventDefault()`を呼ぶことで警告メッセージを出したり、ユーザーの入力値を`localStorage`に逃がしたりできます。

```tsx:useBeforeUnload.tsx
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](https://remix.run/docs/en/main/hooks/use-blocker)
`useBlocker`はナビゲーションにかかわるHookで、ブロック条件に合致する状態の時に、ページ移動をブロックします。
特に長時間の編集作業を伴うようなアプリケーションで、ユーザーの意図しないリンクのクリックにより、編集中の内容が失われたりしないよう制御するケースなどに使えます。

```tsx:useBlocker.tsx
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.

:::note info
拙訳
> ユーザーのページ移動をブロックすることはややアンチパターンですから、このhookの利用は慎重に検討し、控えめにしてください。
途中までフォームを入力した状態でページ移動してしまうことを防ぐという事実上のユースケースにおいては、ページ移動をブロックする代わりに、未保存の内容を`sessionStorage`に保存し、ユーザーが戻ってきたときに自動的に再入力する方法を検討してください。
:::

## [useFetcher](https://remix.run/docs/en/main/hooks/use-fetcher)

`useFetcher`はページ遷移無しでサーバーと通信するためのHookです。
Remixはルートモジュールの中で`loader`⇒`Component`⇒`action`というデータフローに沿ってアプリを動かしていきますが、useFetcherを使うと、ほかのルートモジュールの`loader`を使ってデータを読んだり、`fetcher.Form`の`POST`を通じて、ほかのルートモジュールの`action`を呼んだりできます。

```tsx:useFetcher.tsx
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 />
    </>
  );
}
```

```ts:useFetcher.api.ts
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`が入ってます](https://github.com/remix-run/react-router/blob/0f2b167e9b862d5e6b3425438787c8e7b39197f4/packages/react-router-dom/index.tsx#L1753)ので、より正確には`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](https://remix.run/docs/en/main/hooks/use-fetchers)

`useFetchers`は、アプリケーション全体で**in-flightな**fetcherの配列を取得します。
- `useFetchers`で取得したfetcherには`Form`、`submit`、`load`は含まれておらず、`formData`や`state`などの状態のみが取得できます

```tsx:useFetchers.tsx
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 />
    </>
  );
}
```

```ts:useFetchers.api.ts
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](https://remix.run/docs/en/main/hooks/use-form-action)
`useFormAction`は、`<Form>`の`action`要素に指定する、フォーム送信先のURLを作るためのhookです。現在のパスから最も近いルートや、そこからの相対パスで送信先を指定できます。

```tsx:useFormAction.tsx
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 */}
    </>
  );
}
```

```ts:useFormAction.add.ts
export async function action() {
  console.log('add action called.');
  return null;
}
```

```ts:useFormAction.delete.ts
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](https://remix.run/docs/en/main/hooks/use-href)

`useHref`は`useFormAction`と似たHookで、`<a>`タグなどの`href`要素に渡す絶対パス文字列を作るものです。

```tsx:useHref._index.tsx
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>
    </>
  );
}
```

```tsx:useHref.child.tsx
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を呼んでいる](https://github.com/remix-run/react-router/blob/55c18bfc8e20417204cce5ecda88fe03520ad257/packages/react-router-dom/index.tsx#L1001)ので、`Link`コンポーネントでページナビゲーションしている分には、わざわざこれを単独で使う必要がないです
    - 相対パスから絶対パスに変換したくて、ナビゲーション目的じゃなくて（`<Link>`コンポーネントが使えない）、しかしRemixのルーティングに準拠しURLが欲しいときに使う、という感じだと思うのですが、やはりあまりピンと来ない…

# 終わりに

`useFetcher`は色々試し甲斐がありそうです。
YOMINAでも、`useFetcher`をちゃんと使えていたら、もっときれいな実装になったなぁ、という部分がいくつかあって、調べた甲斐がありました。
やっぱり公式ドキュメントは勉強になりますね…。

ここまでのサンプルコードを動かせるStackblitzを置いておきます。

https://stackblitz.com/edit/remix-run-remix-v6pxff?file=app%2Froutes%2F_index.tsx

`useLoaderData`から先はまた別の記事に、近日中に書こうと思います。