13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-01

ここ数か月、YOMINAというWebアプリを個人で開発しており、フレームワークとしてRemixを使いました。

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

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

前提

公式ドキュメントによると、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を使うことになります
    • 例えば、検索画面のFormmethod="get"にして、searchParamsから検索キーワードやソート順のパラメータを拾って検索、という実装にするケースなど
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がまだ実行されていない場合、useActionDataundefinedを返します
  • 同じルートモジュール内のactionの戻り値が得られます。ほかのルートモジュールからデータを取ってくることはできません
  • 型引数にtypeof actionを渡すと、戻り値の型はaction関数の戻り値に沿った型になります
  • ただし、戻り値の型はシリアライズされており、例えばDateオブジェクトはuseActionDataを通すとstringになります
    • Single Fetchが有効な場合に、action関数から、json関数を通さずに値を返すと、DatePromiseをもとのデータ型のままコンポーネントへと引き渡せるようになります
    • Single Fetchはv2.10.3ではまだunstable扱いですが、今後要注意です

useAsyncError

useAsyncErrorはRemixコンポーネントの<Await>とセットで使うもので、<Await>が待っているPromiserejectされたときに、その内容を取得するHookです。
<Await>errorElementに渡されたコンポーネントが、失敗したPromiseのエラーの内容を知るために使います。

  • <Await>はReactの<Suspense>コンポーネントとセットで使います
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の一つで、loaderactionPromiseを返せるようになると、サーバーで呼び出した非同期処理をコンポーネント側で<Await>できるようになります
    • そうすると、例えばloaderから呼び出している外部APIがエラーを返してきたとき、そのエラーメッセージを拾ってコンポーネントに表示する、ということができるようになります

useAsyncValue

useAsyncValueuseAsyncErrorと同様、Remixコンポーネントの<Await>とセットで使うもので、<Await>が待っているPromiseresolveされたときに、その内容を取得するHookです。
<Await>childrenたちが、Promiseの結果を知るために使います。

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

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

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

useBlockerはナビゲーションにかかわるHookで、ブロック条件に合致する状態の時に、ページ移動をブロックします。
特に長時間の編集作業を伴うようなアプリケーションで、ユーザーの意図しないリンクのクリックにより、編集中の内容が失われたりしないよう制御するケースなどに使えます。

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.

拙訳

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

useFetcher

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

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 />
    </>
  );
}
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が入ってますので、より正確にはfetcher.load()した結果、fetcher.dataが変更されたときに再レンダリングされる、ということみたいです
      • これは結構気を付けないといけない部分な気がする
  • fetcher.dataは初期値undefinedfetcher.load()の後はloaderの戻り値、submit後はactionの戻り値が入ってきます
    • なので、ルートモジュールのloader⇒Component⇒actionの流れと一緒ですね
  • fetcher.stateidlesubmitting(post処理中)、loading(get処理中)の3種類で、そのfetcherが今なにをやってるのかがわかります

useFetchers

useFetchersは、アプリケーション全体でin-flightなfetcherの配列を取得します。

  • useFetchersで取得したfetcherにはFormsubmitloadは含まれておらず、formDatastateなどの状態のみが取得できます
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 />
    </>
  );
}
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

useFormActionは、<Form>action要素に指定する、フォーム送信先のURLを作るためのhookです。現在のパスから最も近いルートや、そこからの相対パスで送信先を指定できます。

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 */}
    </>
  );
}
useFormAction.add.ts
export async function action() {
  console.log('add action called.');
  return null;
}
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/edituser.$id.draft.$articleId.tsxuser.$id.article.$articleId.tsxから送る必要があって、そこで使うフォームを共通化する、とか?

useHref

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

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>
    </>
  );
}
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を呼んでいるので、Linkコンポーネントでページナビゲーションしている分には、わざわざこれを単独で使う必要がないです
    • 相対パスから絶対パスに変換したくて、ナビゲーション目的じゃなくて(<Link>コンポーネントが使えない)、しかしRemixのルーティングに準拠しURLが欲しいときに使う、という感じだと思うのですが、やはりあまりピンと来ない…

終わりに

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

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

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

13
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?