ReactのあたらしいRouterライブラリ「React Location」を試してみた

2021年の11月12日にStable版がリリースされたReact用のあたらしいRouterライブラリ「React Location」を試してみました。Route Loadersを使ったデータ取得、キャッシュ機能、非同期での遷移について紹介しています。
2021.12.06

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。
個人的にはRemixの話題が続いていますが、Remixのリリースされる少し前、2021年の11月12日に突如?あらわれStable版がリリースされたReact用のあたらしいRouterライブラリである「React Location」を今回検証を兼ねて試してみました。

React LocationはReact Queryの作者でもあるTanner Linsley氏(@tannerlinsley)が開発したReact用のあたらしいRouterライブラリです。Tanner氏の手掛けたライブラリはTanStackとも呼ばれており、React TableやReact Chartなど数々のすばらしいライブラリを公開しています。

React用のRouterライブラリとしてはReact Routerが有名でよく利用されているかと思いますが、v5からv6の開発スケジュールにだいぶ時間がかかったこともあり、そのあたりも開発へのモチベーションへとつながったとかいないとか。

React Location got its humble beginnings as a wrapper around the long-winded v6 beta release of React Router.

React Location vs React Router

React Locationの公式ドキュメント内にReact Routerとの機能比較が記載されています。

React Router v6はRemixとの統合的な開発も兼ねていたこともありSSRサポートに強みを持っています。対してReact LocationはReactの強みであるSPAとしての利用をさらに強化するため、CSR下での非同期処理やキャッシュ機能、データ処理やSearchパラメーターのハンドリング機能などが盛り込まれているようです。

さっそく試してみた

React LocationにはRouterライブラリとして基本的な機能はもちろん、React Routerの開発者らしいデータの制御やキャッシュ戦略の機能が組み込まれています。今回はReact Locationを試したところとくに気になった遷移時のデータ取得ができるRoute Loaders、簡易的なキャッシュ機能を持ったReact Location Simple Cacheと非同期でのコンポーネント読み込みを検証を兼ねて試してみました。

今回はCreate React Appで作成したTypeScriptのテンプレートをベースに不要なファイルや記載を削除した形で動作の検証を行っています。ソースについては下記GitHubからも参照いただけます。(Tagによって分類しています)

開発環境

  • Node.js v16.10.0
  • Create React App v4.0.3
  • TypeScript v4.5.2
  • React Location v3.1.0

環境のセットアップ

$ yarn create react-app react-location-sample --template typescript

Create React AppのTypeScriptテンプレートを利用し、index.tsxApp.tsxの記載をほぼ空の状態にして検証していきます。

src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

src/App.tsx

import { VFC } from "react";

export const App:VFC = () => {
  return (
    <div>
      Home
    </div>
  );
}

React Locationのインストール

React Locationと、外部APIへの接続用にaxiosをインストールします。

$ yarn add @tanstack/react-location axios

Routeの構成

今回は下記のURLを想定してRouteを構成します。/postsでは外部APIとしてJSONPlaceholderを利用して記事の一覧を表示し、それぞれの記事への詳細リンク先を表示するという構成です。最初に基本的なRoute機能だけを使ってuseEffectからAPIへ接続し情報を取得するページを作成、その後はuseEffectでの取得部分をRoute Loadersを利用した形へと書き換え検証していきます。

パス 内容 コンポーネント
/ トップページ Homeコンポーネント
/posts 記事一覧 PostIndexコンポーネント
/posts/:postId 記事詳細 PostDetailコンポーネント

React LocationはRouteのルールを記載にオブジェクトの形で記載します。React Routerと同様にライブラリを追加することでJSXスタイルでの記載も可能ですが公式ドキュメントではオブジェクト形式での記載となっているためオブジェクト形式での記載がオススメです。(ちなみにReact Routerもオブジェクト形式での記載をサポートしています)

Router.tsxファイルとしてオブジェクト形式でパスを記載し、App.tsxに組み込んでいきます。elementとして読み込むコンポーネントは別途作成します。

src/Router.tsx

import { Route, ReactLocation } from '@tanstack/react-location';
import { Home } from './components/Home';
import { PostDetail } from './components/PostDetail';
import { PostIndex } from './components/PostIndex';
import type { Post } from './types';

export const location = new ReactLocation();

export const routes: Route[] = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: 'posts',
    children: [
      {
        path: '/',
        element: <PostIndex />,
      },
      {
        path: ':postId',
        element: <PostDetail />,
      },
    ],
  },
];

Postの型定義

src/types/index.ts

export type Post = {
  id: string;
  title: string;
  body: string;
};

App.tsxの``の部分にRouter.tsxファイルで設定した部分がパスに応じてレンダリングされます。

src/App.tsx

import { VFC } from 'react';
import { Router, Outlet, Link } from '@tanstack/react-location';
import { routes, location } from './Router';

export const App:VFC = () => {
  return (
    <Router routes={routes} location={location}>
      <div>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="posts">Posts</Link>
          </li>
        </ul>
      </div>
      <Outlet /> {/* パスが一致した際にレンダリングされるコンポーネント */}
    </Router>
  );
}

コンポーネントの作成

src/components配下に3つのコンポーネントを作成します。またJSONplaceholderへの接続用の関数をlib/fetchPosts.tsとして用意します。

トップページ用のコンポーネント

src/components/Home.tsx

import { VFC } from 'react';

export const Home: VFC = () => {
  return <div>Home</div>;
};

記事一覧表示用のコンポーネント

src/components/PostIndex.tsx

import { useEffect, useState, VFC } from 'react';
import { Link } from 'react-location';
import { fetchPosts } from '../lib/fetchPosts';
import type { Post } from '../types';

export const PostIndex: VFC = () => {
  const [posts, setPosts] = useState<Post[] | null>([]);

  useEffect(() => {
    fetchPosts().then(setPosts);
  }, []);

  if (!posts) return null;

  return (
    <div>
      <h1>Post Index</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

記事詳細表示用のコンポーネント

src/components/PostDetail.tsx

import { useEffect, useState, VFC } from 'react';
import { useMatch } from 'react-location';
import { fetchPostById } from '../lib/fetchPosts';
import { Post } from '../types';

export const PostDetail: VFC = () => {
  const { postId } = useMatch().params;
  const [post, setPost] = useState<Post | null>();

  useEffect(() => {
    if (postId) {
      fetchPostById(postId).then(setPost);
    } else {
      setPost(null);
    }
  }, [postId]);

  if (!post) return null;

  return (
    <div>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </div>
  );
};

JSONplaceholder API接続用関数

src/lib/fetchPosts.ts

import axios from 'axios';
import type { Post } from '../types';

export async function fetchPosts() {
  // APIへの接続時間を擬似的に延長する
  await new Promise((r) => setTimeout(r, 1000));

  return await axios
    .get<Post[]>('https://jsonplaceholder.typicode.com/posts')
    .then((r) => r.data.slice(0, 5));
}

export async function fetchPostById(postId: string) {
  // APIへの接続時間を擬似的に延長する
  await new Promise((r) => setTimeout(r, 1000));

  return await axios
    .get<Post>(`https://jsonplaceholder.typicode.com/posts/${postId}`)
    .then((r) => r.data);
}

ここまでできたところで動作を確認します。

$ yarn start

記事一覧、記事詳細画面がそれぞれJSONplaceholder APIからの情報をもとに表示されました。ページ遷移時にuseEffectでAPIから情報を取得する際に1秒の疑似タイマーをはさんでいるため、クリックし、ページ遷移後にページタイトルのみが表示され、その後少したって一覧が表示されることを確認できると思います。

useEffectの場合

ここまではReact Locationの基本的なRoute機能のみを試してみました。従来のRouterライブラリと書き方が異なるだけでほとんど同じように扱うことができそうです。ここからReact Locationの個人的注目機能Route Loadersを使った形へを書き換えを行います。

この時点でのソースファイルはこちら

Route Loaders

Route Loadersはルートの変更などをトリガーとして動作する関数で、ルート遷移時データを取得したり、非同期の動作を完了するまでページ遷移を待機させたりすることが可能です。ここまで作成した従来のページ遷移方法だとページ遷移後にuseEffectでコンポーネントのマウント後にデータの取得がはじまるため、ユーザーはローディング画面(今回は真っ白な画面)を何度も見ることになります。Route Loadersはデータを事前に用意してからレンダリングした方がユーザー体験上好ましいという考えのものと実装されているようです。

さっそくRouter.tsxをRoute Loadersを利用した形へ書き換えていきます。

src/Router.tsx

import { Route, ReactLocation, MakeGenerics } from '@tanstack/react-location';
import { Home } from './components/Home';
import { PostDetail } from './components/PostDetail';
import { PostIndex } from './components/PostIndex';
import { fetchPostById, fetchPosts } from './lib/fetchPosts';
import type { Post } from './types';

export type LocationGenerics = MakeGenerics<{
  LoaderData: {
    posts: Post[];
    post: Post;
  };
}>;

export const location = new ReactLocation();

export const routes: Route<LocationGenerics>[] = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: 'posts',
    children: [
      {
        path: '/',
        loader: async () => {
          return {
            posts: await fetchPosts(),
          };
        },
        element: <PostIndex />,
      },
      {
        path: ':postId',
        loader: async ({ params }) => {
          return {
            post: await fetchPostById(params.postId),
          };
        },
        element: <PostDetail />,
      },
    ],
  },
];

記事一覧画面

Route Loaders内でデータの取得を行うため表示側のコンポーネントではデータの取得が不要になります。useMatchを利用することでLoaderから渡されたデータを利用することができます。APIへの接続がコンポーネントから分離され完全に外部の責務となるためソースコードもかなりすっきりとしますね。

src/components/PostIndex.tsx

import { VFC } from 'react';
import { Link, useMatch } from 'react-location';
import { LocationGenerics } from '../Router';

export const PostIndex: VFC = () => {
  const {
    data: { posts },
  } = useMatch<LocationGenerics>();

  if (!posts) return null;

  return (
    <div>
      <h1>Post Index</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};

記事詳細画面

src/components/PostDetail.tsx

import { VFC } from 'react';
import { useMatch } from 'react-location';

import type { LocationGenerics } from '../Router';

export const PostDetail: VFC = () => {
  const {
    data: { post },
  } = useMatch<LocationGenerics>();

  if (!post) return null;

  return (
    <div>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </div>
  );
};

APIへの接続ロジックをRoute Loadersを利用する形に書き換えたところで再度動作を確認してみましょう。

$ yarn start

記事一覧や記事詳細へと遷移する際に遅れて表示されていたAPIの情報がページ遷移完了と同時に表示されることが確認できたでしょうか。このようにRoute Loadersを利用するとデータの取得を待ってから遷移しレンダリングを行うためページのレイアウトが一瞬崩れるなどユーザー体験を損なわずにページを表示することができるようになります。

Route Loadersの場合

React Location Simple Cache

次にキャッシュ機能であるReact Location Simple Cacheについて検証していきます。React Location Simple CacheRoute Loadersの中で利用できるキャッシュ機能となり、別途インストールする必要があります。またキャッシュ機能を利用するにあたってDevtoolsをあわせてインストールすると動作を確認しながら開発をすすめることが可能です。

インストール

$ yarn add react-location-simple-cache react-location-devtools

Devtoolの設定

Devtoolはデフォルトでは環境がdevelopment時にのみブラウザ上で動作するツールです。React Queryにも同様のツールがありますが、こちらも同じ用に使用することができます。

App.tsxに記載を追加します。

src/App.tsx

...
import { ReactLocationDevtools } from 'react-location-devtools';

export const App: VFC = () => {
  return (
    <Router routes={routes} location={location}>
      ...省略
      <Outlet /> {/* パスが一致した際にレンダリングされるコンポーネント */}
      <ReactLocationDevtools initialIsOpen={false} />
    </Router>
  );
};

この状態で開発サーバーを起動するとブラウザの左下にReact Locationのアイコンが表示されるので、このアイコンをクリックしてツールを開きます。ページの遷移時に何が渡っているのか、現在持っている情報はどういったものなのかといったことが確認できるため開発時には非常に役に立ちます。

Simple Cacheの設定

Loaderの部分にSimple Cacheの設定を記載します。設定できるオプションは多数あるのですが今回はキャッシュポリシーのみ設定を変更します。

ポリシー 内容
cache-and-network デフォルトの設定。キャッシュがある場合はキャッシュを利用し、裏で新規に取得しキャッシュを入れ替える
cache-first キャッシュがある限りキャッシュのみを利用する
network-only 常に取得を行い、キャッシュにも格納する

その他のオプションなどはドキュメントを参照ください。

今回は一覧画面にcache-and-networkを、詳細画面にはcache-firstを設定してみます。

src/Router.tsx

import { Route, ReactLocation, MakeGenerics } from 'react-location';
import { ReactLocationSimpleCache } from 'react-location-simple-cache';
import { Home } from './components/Home';
import { PostDetail } from './components/PostDetail';
import { PostIndex } from './components/PostIndex';
import { fetchPostById, fetchPosts } from './lib/fetchPosts';
import type { Post } from './types';

export type LocationGenerics = MakeGenerics<{
  LoaderData: {
    posts: Post[];
    post: Post;
  };
}>;

export const location = new ReactLocation();

export const routeCache = new ReactLocationSimpleCache();

export const routes: Route<LocationGenerics>[] = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: 'posts',
    children: [
      {
        path: '/',
        loader: routeCache.createLoader(
          async () => {
            return {
              posts: await fetchPosts(),
            };
          },
          {
            policy: 'cache-and-network',
          },
        ),
        element: <PostIndex />,
      },
      {
        path: ':postId',
        loader: routeCache.createLoader(
          async ({ params }) => {
            return {
              post: await fetchPostById(params.postId),
            };
          },
          {
            policy: 'cache-first',
            maxAge: 1000 * 60, // キャッシュを利用する時間を最大60秒に
          },
        ),
        element: <PostDetail />,
      },
    ],
  },
];

動作を試してみます。

Home画面からPostsをクリックして記事一覧画面に遷移します。キャッシュにはデータがないためデータを取得するまで遷移を止め、取得後に遷移を行います。遷移後にキャッシュへ格納するためにデータをバックグラウンドで取得します。
その後再度Home画面に遷移し、再度Postsをクリックして記事一覧画面に遷移します。今回はキャシュがあるため取得を行わずにページ遷移を行い、完了後にバックグラウンドでキャッシュへ格納するためのデータを取得するといったいった流れを確認することができたでしょうか。

同様に記事詳細画面も確認してみます。
記事一覧画面から1つ目の記事へ遷移すると取得が開始します。再度一覧画面へ戻ってから詳細記事へ遷移しても再取得は行わずにキャッシュを利用して表示を行っています。

ここまでのソースファイル

コンポーネントの非同期での読み込み

さいごにコンポーネントを非同期で読み込む形へ変更していきます。moduleという形で非同期にimportすることで肥大しがちなReactアプリケーションの初回の読み込みファイルを削減することが可能です。今回はmoduleファイルをコンポーネントごと新規に用意し、Route Loaders等はmoduleファイルから実行、また表示コンポーネントについてはそこからさらに非同期で読み込む形とします。

src/Router.tsx

import { Route, ReactLocation, MakeGenerics } from 'react-location';
import { ReactLocationSimpleCache } from 'react-location-simple-cache';
import { Home } from './components/Home';
import { PostIndex } from './components/PostIndex';
import type { Post } from './types';

export type LocationGenerics = MakeGenerics<{
  LoaderData: {
    posts: Post[];
    post: Post;
  };
}>;

export const location = new ReactLocation();

export const routeCache = new ReactLocationSimpleCache();

export const routes: Route<LocationGenerics>[] = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: 'posts',
    children: [
      {
        path: '/',
        import: () =>
          import('./components/PostIndex.module').then(
            (module) => module.PostIndexModule,
          ),
      },
      {
        path: ':postId',
        import: () =>
          import('./components/PostDetail.module').then(
            (module) => module.PostDetailModule,
          ),
      },
    ],
  },
];

moduleファイル

新規にファイルを作成します。

src/components/PostIndex.module.tsx

import { Route } from 'react-location';
import { fetchPosts } from '../lib/fetchPosts';
import { LocationGenerics, routeCache } from '../Router';

export const PostIndexModule: Route<LocationGenerics> = {
  loader: routeCache.createLoader(
    async () => {
      return {
        posts: await fetchPosts(),
      };
    },
    {
      policy: 'cache-and-network',
    },
  ),
  element: () => import('./PostIndex').then((module) => <module.PostIndex />),
};

src/components/PostDetail.module.tsx

import { Route } from 'react-location';
import { fetchPostById } from '../lib/fetchPosts';
import { LocationGenerics, routeCache } from '../Router';

export const PostDetailModule: Route<LocationGenerics> = {
  loader: routeCache.createLoader(
    async ({ params }) => {
      return {
        post: await fetchPostById(params.postId),
      };
    },
    {
      policy: 'cache-first',
      maxAge: 1000 * 60, // キャッシュを利用する時間を最大60秒に
    },
  ),
  element: () => import('./PostDetail').then((module) => <module.PostDetail />),
};

ファイルを追加したところで動作を確認してみます。
記事一覧ページ、記事詳細ページへと遷移するごとに分割されたファイルが読み込まれるのが確認できました。

また、アプリケーションの初回に読み込まれるjsファイルのサイズについても、下記のようにサイズを減らすことができています。参考にするにはソース量も読み込んでいるライブラリも少ないのであまり差はありませんが、各コンポーネントが大きくなるにつれ効果は大きくなっていくはずです。

ファイルサイズ
非同期読み込み前 66.8KB
非同期読み込み対応 58.2KB

なお、コンポーネントの非同期の読み込みについてはReact Router v6でもReact.lazyとReact.Suspenseを利用することで実現は可能です:)

また、特にロジックを含まない、もしくはコンポーネントのみを非同期で読み込みたい場合はmoduleファイルは作成せずに下記のようにelement部分のみimportをすればOKです。

src/Router.tsx

import { Route, ReactLocation, MakeGenerics } from 'react-location';
import { ReactLocationSimpleCache } from 'react-location-simple-cache';
import { Home } from './components/Home';
import { fetchPosts } from './lib/fetchPosts';
import type { Post } from './types';

// 省略

export const routes: Route<LocationGenerics>[] = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: 'posts',
    children: [
      {
        path: '/',
        loader: routeCache.createLoader(
          async () => {
            return {
              posts: await fetchPosts(),
            };
          },
          {
            policy: 'cache-and-network',
          },
        ),
        element: () =>
          import('./components/PostIndex').then((module) => (
            <module.PostIndex />
          )),
      },
      {
        path: ':postId',
        import: () =>
          import('./components/PostDetail.module').then(
            (module) => module.PostDetailModule,
          ),
      },
    ],
  },
];

ここまでのソースファイル

さいごに

React Locationの機能について、とくに気になった機能へ焦点をあてて検証してみました。個人的にはCSRを利用する際のRouterライブラリの選択肢としてすぐにでも実戦投入できるのではないかと感じています。とくにRoute Loadersはかなり強力な機能でCSRのRouterライブラリの概念を変えていくようにも思いました。各コンポーネントの責務を分離することでテスタブルな構成もつくりやすそうです。

TanStackにあたらしいReactフレームワークとかあらわれたら面白そうですね:)

参考サイト・参考情報