🪩

Remixを使用する前に知りたかったこと

2024/12/21に公開

はじめに

こちらは e-dash advent calendar 2024 21 日目の記事です。
こんにちは。e-dash のフロントエンドエンジニア yuuki1036 です。アドベントカレンダーが何なのかどこから来たのかは分かっていないです。
弊社のプロダクトでは主に Next.js を採用していますが、今回社内向けのアプリケーションを開発する機会があり Remix を採用しました。私自身 Remix を使用するのは初めてだったので初学者の視点から、今にして思う使用前に知っておきたかったことや Tips をまとめてみました。(Remix っていいよね、という方向性の記事です)

Outlet

Outlet は Remix のルーティングで使用できる独自のコンポーネントで、親ルートから子ルートのコンテンツを表示するためのプレースホルダーのような役割を果たします。
Remix はファイルベースルーティングを採用しており、ファイル名でルートを.で区切って設定します。ちなみに Next.js はディレクトリベースルーティングです。
Outlet が配置されたルートは親ルートとなり、ルート名の前後に _ を付与することで対象のセグメントごとに振る舞いを指示できます。

  • hoge :コンポーネントが継承され URL パスに含まれる
  • _hoge :コンポーネントが継承され URL パスに含まれない
  • hoge_ :コンポーネントが継承されないが URL パスに含まれる
routes/
├── _layout.tsx // <Outlet/> が配置された親ルート
└── _layout.contents.tsx // layout を継承した子ルート URL: /contents
└── _layout.contents_.detail.tsx // layout を継承した子ルート URL: /contents/detail

Outlet はネストできる

Outlet の活用例としてレイアウトの中に独立したコンテンツが配置されるものをよく見かけますが、Outlet は複数階層に渡ってネストすることができます。

useOutletContext は直下のルートでのみ使用できる

useOutletContext は親ルートの値を子ルートで取得できるフックで、Outlet の引数に渡すことで取得可能になります。Context という名前で勘違いしやすいですが、取得できる範囲は子ルートのみでありネストした孫階層やクライアントコンポーネントでは取得できません。

export default function Route() {
  const { data } = useLoaderData<typeof loader>();
  return (
    <div>
      <header />
      // 子ルートでuseOutletContext経由でdataを取得できる
      <Outlet context={{ data }} />
      <footer />
    </div>
  );
}

context prop にバケツリレー的にデータを渡せば、孫階層でも取得できるようになりますが、その場合は React Context API 等で状態管理を行う方が簡潔です。

Outlet はどんなときに使う?

Outlet の使用はパフォーマンスにも寄与します。Remix は Outlet で区分されたルートごとに並列でデータを取得してくれるので、データフローが独立したコンテンツは Outlet で分割するのが良さそうです。

上記を踏まえると Outlet はデータフローが複雑にならない範囲でのレイアウトとコンテンツの分割に適していると思いました。また各コンテンツのデータフローが独立している場合は高いパフォーマンスを得られるでしょう。

Action

Remix の action はルートで定義され、フォームの送信処理をサーバー側で行うことができます。また、useFetcher と呼ばれるフックがあり、こちらもユーザーの操作による処理をサーバー側で行うことができます。
サーバー側で処理を行うことでクライアント側のバンドルサイズを小さくできる利点があります。
Next.js App Router の Server Actions と違い Remix の action は実行時にページ全体を再レンダリングします。

action と useFetcher の動作と使い分け

  • action
    フォームの POST 送信のみに使用できます。フォームの送信時に親ルートの loader データを含めて再検証を行いページ全体を再レンダリングします。
    シンプルにフォーム送信時に使用するのが良さそうです。

  • useFetcher
    任意のコンポーネントで使用できます。実行時に親ルートの loader データを含めて再検証を行い、hook に紐付いた対象のコンポーネントのみを再レンダリングします。
    部分的にデータ更新を行いたいが、loader データの再検証も行いたい場合に便利そうです。

loader データの再検証動作を確認するためのサンプルコード
// app/routes/action-parent.tsx
export function loader() {
  console.log('parent loader called!');
  return {
    data: 'loader data of parent',
  };
}

export default function Route() {
  const { data } = useLoaderData<typeof loader>();
  return (
    <div>
      <p>parent loader data: {data}</p>
      <Outlet />
    </div>
  );
}

// app/routes/action-parent.action.tsx
export async function action({ request }: { request: Request }) {
  const formData = await request.formData();
  console.log('submit data', formData);
  return null;
}

export function loader() {
  console.log('child loader called!');
  return {
    data: 'loader data of child',
  };
}

export default function Route() {
  const { data } = useLoaderData<typeof loader>();
  return (
    <div>
      <form method="post">
        <p>action form</p>
        <input type="text" name="text" />
        <button type="submit">submit</button>
      </form>
      <p>loader data: {data}</p>
    </div>
  );
}

// app/routes/action-parent.use-fetcher.tsx
export async function action({ request }: { request: Request }) {
  const formData = await request.formData();
  console.log('submit data', formData);
  return null;
}

export function loader() {
  console.log('child loader called!');
  return {
    data: 'loader data of child',
  };
}

export default function Route() {
  const { data } = useLoaderData<typeof loader>();
  const fetcher = useFetcher();
  return (
    <div>
      <fetcher.Form method="post">
        <p>use fetcher form</p>
        <input type="text" name="text" />
        <button type="submit">submit</button>
      </fetcher.Form>
      <p>loader data: {data}</p>
    </div>
  );
}

// /action-parent/action, /action-parent/use-fetcher でそれぞれフォームを送信したときの出力
// submit data FormData { text: 'xxx' }
// parent loader called!
// child loader called!

useFetcher

上記で触れた useFetcher については他にも利点があるので深堀りしていきたいと思います。

競合状態を自動的に制御してくれる

useFetcher は、リクエストが競合した状態、いわゆるレースコンディションを自動的に制御してくれます。リクエスト処理中に新しいリクエストが送信された場合、古いリクエストを破棄して新しいリクエストを送信します。

オートコンプリート付きテキストフィールドで検証してみました。
テキストボックスに入力した文字列で国のリストから絞り込んだ結果を表示します。レースコンディションを再現しやすくするため入力文字数ごとに遅延を大きくしています。
1-2 文字目の入力によるリクエストが破棄され、3 文字目の入力によるリクエストによる結果が表示されていることがわかります。

type FormData = {
  searchResults: Country[];
};

export async function action({ request }: { request: Request }) {
  const formData = await request.formData();
  const searchText = formData.get('text') as string;
  // 入力文字数に対して遅延をつける
  const delay = searchText.length * 500;

  // 疑似API呼び出し
  const searchResults = await new Promise<Country[]>((resolve) => {
    setTimeout(() => {
      const filteredSuggestions = countries.filter((country) =>
        country.name.toLowerCase().includes(searchText.toLowerCase())
      );
      resolve(filteredSuggestions);
    }, delay);
  });

  return {
    searchResults,
  };
}

export default function Route() {
  const fetcher = useFetcher<FormData>();

  const handleChange = (e: React.ChangeEvent<HTMLFormElement>) => {
    const formData = new FormData(e.currentTarget);
    fetcher.submit(formData, {
      method: 'post',
    });
  };

  return (
    <div>
      <fetcher.Form method="post" onChange={handleChange}>
        <p>search form by useFetcher</p>
        <input type="text" name="text" />
      </fetcher.Form>
      <div>
        {fetcher.data?.searchResults.map((result) => (
          <div key={result.id}>{result.name}</div>
        ))}
      </div>
    </div>
  );
}

一方で React の useState を使用し、レースコンディションを考慮しない場合は古いリクエストから順に結果が表示されることで以下のような挙動になってしまいます。

Optimistic UI

useFetcher を使用すると、API のレスポンスを待たずにクライアント側の UI を更新するような楽観的 UI 更新を比較的簡単に実装できます。
DB への保存処理を待たず結果を反映したいときなどの UX を向上させることができます。SNS の like 数のカウント更新などがいい例ですね。

TODO リストを用いて楽観的 UI 更新を検証してみました。
テキストボックスに入力して送信したとき TODO リスト即時に反映されます。サーバーレスポンスが返ってきたらもう一度 TODO リストを更新して値が確定します。

動作として、フォーム送信直後はフォームの入力データを用いて UI を更新し、サーバーからのレスポンスが返ってきたらサーバーからのデータを反映します。useFetcher は内部で送信時のフォームデータ(formData)・サーバーレスポンス(data)・送信ステータス(state)を保持しているので、それらを用いて楽観的 UI 更新を実装できます。

type Todo = {
  id: string;
  text: string;
};

export async function action({ request }: { request: Request }) {
  const formData = await request.formData();
  const text = formData.get('text') as string;
  // 疑似DB保存
  const newTodo = await new Promise<Todo>((resolve) =>
    setTimeout(() => {
      resolve({
        id: Math.random().toString(36).substring(7),
        text,
      });
    }, 1000)
  );
  return { todo: newTodo };
}

export async function loader() {
  // 実際のアプリケーションではデータベースから取得
  return {
    todos: [
      { id: '1', text: '起きる' },
      { id: '2', text: '歯磨き' },
    ],
  };
}

export default function Todos() {
  const { todos } = useLoaderData<typeof loader>();
  const fetcher = useFetcher<{ todo: Todo }>();
  const formRef = useRef<HTMLFormElement>(null);

  // 送信完了時にフォームをリセット
  useEffect(() => {
    if (fetcher.state === 'idle' && formRef.current) {
      formRef.current.reset();
    }
  }, [fetcher.state]);

  // 送信完了までの間に表示する一時的なデータ
  let optimisticTodos = [...todos];

  // フォーム送信時のデータをUIに反映
  if (fetcher.formData) {
    const text = fetcher.formData.get('text') as string;
    optimisticTodos = [
      ...todos,
      {
        // DBのデータと区別するためprefixをつける
        id: 'optimistic-' + Math.random(),
        text,
      },
    ];
  }

  // サーバーからの応答データを反映
  if (fetcher.data?.todo) {
    optimisticTodos = [...todos, fetcher.data.todo];
  }

  return (
    <div>
      <p>optimistic todo list</p>
      <fetcher.Form ref={formRef} method="post">
        <input type="text" name="text" required />
        <button type="submit">Add</button>
      </fetcher.Form>

      {/* 送信ステータス */}
      <p>status: {fetcher.state}</p>

      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} className="list-disc">
            <span>{todo.text}</span>
            {todo.id.startsWith('optimistic') && <span>(保存中...)</span>}
          </li>
        ))}
      </ul>
    </div>
  );
}

React 19 で追加された useOptimistic を使用することでも同様の動作を実装できますが、1 つの hook にデータが集約されているという点で useFetcher の方が実装しやすいように感じました。

感想

Remix は Next.js と比べるとコミュニティの規模が小さく感じますが、思想や機能面において App Router に対抗できる良いフレームワークだなと感じました。
先月の React Router v7 のリリースで React Router のフレームワークモードとして統合されたり、RSC 対応の開発も進んでいたりで、今後も目が離せませんね!

Discussion