💡

React Router v6のOutletとReact.lazyの組み合わせが便利

2022/08/09に公開

React Router v6 で登場した <Outlet /> を活用すると、良い感じに共通レイアウトを組み込めて、さらに <React.Suspense>React.lazy() を組み合わせると体験が良くなるのを見つけました。おそらく React Router v6 を利用する上での頻出イディオムになると思うので、自分用のメモとして残しておきます。

最終的なサンプルコードはこちら。

使用したライブラリは次のとおりです。見た目がしょぼいとテンションが下がるので MUI で装飾していますが、本筋とは関係ないので今回は特に触れません。

  • react@18.0.0
  • react-dom@18.0.0
  • react-router-dom@6.3.0
  • @mui/material@5.9.3
  • @mui/icons-material@5.8.4
  • @emotion/react@11.10.0
  • @emotion/styled@11.10.0

最初のバージョン:各ページで冗長にレイアウトを定義する

まずは愚直に各ページを実装して、React Router でルーティングしてみましょう。トップページ、ユーザー一覧、商品一覧の 3 つのページを定義します。

src/App.tsx
import CssBaseline from "@mui/material/CssBaseline";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import IndexPage from "./pages/IndexPage";
import UsersPage from "./pages/UsersPage";
import ItemsPage from "./pages/ItemsPage";

const theme = createTheme();

export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Box sx={{ display: "flex" }}>
        <CssBaseline />
        {/* React Routerのルーティング実装はここから */}
        <BrowserRouter>
          <Routes>
            <Route path="/">
              <Route index element={<IndexPage />} />{/* トップページ */}
              <Route path="/users" element={<UsersPage />} />{/* ユーザー一覧 */}
              <Route path="/items" element={<ItemsPage />} />{/* 商品一覧 */}
            </Route>
          </Routes>
        </BrowserRouter>
        {/* React Routerのルーティング実装はここまで */}
      </Box>
    </ThemeProvider>
  );
}

React Router v5 までと <Route /> の雰囲気が変わりましたね。気になる方は v5 to v6 のマイグレーションガイドを読んでください。

次に、各ページの実装を見てみましょう。とは言っても、サンプルなのでほとんど同じ実装にしてあります。代表例として <IndexPage /> の実装を見てみましょう。

src/pages/IndexPage.tsx
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Container from "@mui/material/Container";
import { useState } from "react";
import { AppBar } from "../components/AppBar";
import { MenuDrawer } from "../components/MenuDrawer";
import { Typography } from "@mui/material";

const IndexPage = () => {
  const [open, setOpen] = useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  return (
    <>
      <AppBar open={open} toggleDrawer={toggleDrawer} />
      <MenuDrawer open={open} toggleDrawer={toggleDrawer} />
      <Box
        component="main"
        sx={{
          backgroundColor: (theme) =>
            theme.palette.mode === "light"
              ? theme.palette.grey[100]
              : theme.palette.grey[900],
          flexGrow: 1,
          height: "100vh",
          overflow: "auto"
        }}
      >
        <Toolbar />
        <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
          <Typography variant="h2">サンプルアプリ</Typography>
          <Typography>ここはトップページです</Typography>
        </Container>
      </Box>
    </>
  );
};

export default IndexPage;

これを表示すると、次のような見た目になります。

サンプルアプリのトップページ

<AppBar><MenuDrawer> はやや込み入った実装にしてありますが、本筋ではないので解説を割愛します。

ユーザー一覧と商品一覧は、文言を変えただけで、ほぼ同じ実装にしました。

ユーザー一覧の実装
src/pages/UsersPage.tsx
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Container from "@mui/material/Container";
import { useState } from "react";
import { AppBar } from "../components/AppBar";
import { MenuDrawer } from "../components/MenuDrawer";
import { Typography } from "@mui/material";

const UsersPage = () => {
  const [open, setOpen] = useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  return (
    <>
      <AppBar open={open} toggleDrawer={toggleDrawer} />
      <MenuDrawer open={open} toggleDrawer={toggleDrawer} />
      <Box
        component="main"
        sx={{
          backgroundColor: (theme) =>
            theme.palette.mode === "light"
              ? theme.palette.grey[100]
              : theme.palette.grey[900],
          flexGrow: 1,
          height: "100vh",
          overflow: "auto"
        }}
      >
        <Toolbar />
        <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
          <Typography variant="h2">ユーザー一覧ページ</Typography>
          <Typography>ここはユーザー一覧ページです</Typography>
        </Container>
      </Box>
    </>
  );
};

export default UsersPage;
商品一覧の実装
src/pages/ItemsPage.tsx
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Container from "@mui/material/Container";
import { useState } from "react";
import { AppBar } from "../components/AppBar";
import { MenuDrawer } from "../components/MenuDrawer";
import { Typography } from "@mui/material";

const ItemsPage = () => {
  const [open, setOpen] = useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  return (
    <>
      <AppBar open={open} toggleDrawer={toggleDrawer} />
      <MenuDrawer open={open} toggleDrawer={toggleDrawer} />
      <Box
        component="main"
        sx={{
          backgroundColor: (theme) =>
            theme.palette.mode === "light"
              ? theme.palette.grey[100]
              : theme.palette.grey[900],
          flexGrow: 1,
          height: "100vh",
          overflow: "auto"
        }}
      >
        <Toolbar />
        <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
          <Typography variant="h2">商品一覧ページ</Typography>
          <Typography>ここは商品一覧ページです</Typography>
        </Container>
      </Box>
    </>
  );
};

export default ItemsPage;

ヘッダーとサイドメニュー部分はまったく同じ実装なのに、ページごとにいちいち記述していく冗長な実装です。

差分は <Container> の中だけです。画面でいうと、次の部分ですね。

差分のエリア

できれば <HogePage> に実装するのは、この差分の部分だけにしたいですね……

Outlet でレイアウトを共用する

というわけで、外側の共通部は共有しつつ、内側だけルーティングしたい欲求が生まれました。

ここで役に立つのが React Router v6 で登場した <Outlet /> です。<Outlet /> を含むコンポーネントを上位の <Route> に設定しておくと、子孫の <Route> に定義したコンポーネントが <Outlet /> を置き換える形でレンダリングされます。

実際のやり方を見てみましょう。まずは共通コンポーネントとして src/pages/Layout.tsx を定義します。

src/pages/Layout.tsx
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Container from "@mui/material/Container";
import { useState } from "react";
import { AppBar } from "../components/AppBar";
import { MenuDrawer } from "../components/MenuDrawer";
import { Outlet } from "react-router-dom";

const Layout = () => {
  const [open, setOpen] = useState(true);
  const toggleDrawer = () => {
    setOpen(!open);
  };

  return (
    <>
      <AppBar open={open} toggleDrawer={toggleDrawer} />
      <MenuDrawer open={open} toggleDrawer={toggleDrawer} />
      <Box
        component="main"
        sx={{
          backgroundColor: (theme) =>
            theme.palette.mode === "light"
              ? theme.palette.grey[100]
              : theme.palette.grey[900],
          flexGrow: 1,
          height: "100vh",
          overflow: "auto"
        }}
      >
        <Toolbar />
        <Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
          <Outlet />{/* ここが置き換わる */}
        </Container>
      </Box>
    </>
  );
};

export default Layout;

一見すると前述のページ実装と変わらないように見えますが、 <Container> の中に <Outlet /> が配置されました。ここが各ページの実装に置き換わるようになります。

では、これをルーティングの設定に組み込んでみましょう。

src/App.tsx
import CssBaseline from "@mui/material/CssBaseline";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import IndexPage from "./pages/IndexPage";
import UsersPage from "./pages/UsersPage";
import ItemsPage from "./pages/ItemsPage";
import Layout from "./pages/Layout"; // 追加

const theme = createTheme();

export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Box sx={{ display: "flex" }}>
        <CssBaseline />
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Layout />}>{/* elementを追加 */}
              <Route index element={<IndexPage />} />
              <Route path="/users" element={<UsersPage />} />
              <Route path="/items" element={<ItemsPage />} />
            </Route>
          </Routes>
        </BrowserRouter>
      </Box>
    </ThemeProvider>
  );
}

<Route path="/"> の定義に element 属性を追記しました。これで子孫要素が <Layout /> の影響を受けるようになります。

この時点で表示してみると、メニューが二重に表示されて崩れます。見た目には分かりませんが、おそらく <AppBar> も重なって 2 つ表示されています。

崩れた

外側のメニューが <Layout /> 由来、内側のメニューが <ItemsPage /> に実装されたままになっている <MenuDrawer> 由来の UI です。

あとは各ページに実装してある <AppBar><MenuDrawer> を消すだけですね!

<IndexPage /> は、こうなります。

src/pages/IndexPage.tsx
import { Typography } from "@mui/material";

const IndexPage = () => {
  return (
    <>
      <Typography variant="h2">サンプルアプリ</Typography>
      <Typography>ここはトップページです</Typography>
    </>
  );
};

export default IndexPage;

ユーザー一覧、商品一覧も同様です。

src/pages/UsersPage.tsx
import { Typography } from "@mui/material";

const UsersPage = () => {
  return (
    <>
      <Typography variant="h2">ユーザー一覧ページ</Typography>
      <Typography>ここはユーザー一覧ページです</Typography>
    </>
  );
};

export default UsersPage;
src/pages/ItemsPage.tsx
import { Typography } from "@mui/material";

const ItemsPage = () => {
  return (
    <>
      <Typography variant="h2">商品一覧ページ</Typography>
      <Typography>ここは商品一覧ページです</Typography>
    </>
  );
};

export default ItemsPage;

見事に各ページが差分だけの実装になりました。

もちろん問題なく表示されます。

レイアウトの共通化ができた

<Outlet /> を活用することで、見た目を共有するレイアウトの実装を合わせることができました。

実は、嬉しいのは実装の共有だけではありません。React Router は今回の設定において、 <Route path="/"> の中でページ移動をしている限り、 <Layout /> で表示した外側の部分を再レンダリングせずに、 <Outlet /> 部分だけを書き換えてくれるのです。画面の描画コストを抑えてくれる素晴らしい仕組みといえるでしょう。

lazy と Suspense を組み合わせる

さて、ここまでの内容でも十分に嬉しいのですが、さらにアプリケーションサイズを減らす施策も行ってみましょう。

React には、コンポーネントの Dynamic Import と React.lazy()<React.Suspense> を組み合わせることで、コード分割を行う仕組みがあります。

https://ja.reactjs.org/docs/code-splitting.html

これが <Outlet /> と相性が良かったので、ある種のイディオムとして紹介しますね。

どうするのかというと、 <Outlet /><Suspense> で囲みます。

src/pages/Layout.tsx
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
  <Suspense fallback={<div>Loading...</div>}>
    <Outlet />
  </Suspense>
</Container>

上記の実装によって、各ページの読み込み中に <Container> の中だけが「Loading...」の表示になることを期待します。

あとは教科書通りに、 React.lazy() と Dynamic Import によるコード分割の実装を行うだけです。

src/App.tsx
import CssBaseline from "@mui/material/CssBaseline";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Layout from "./pages/Layout";
// ページのimport from文はdynamic importに置き換えるので削除
import { lazy } from "react"; // 追加

const IndexPage = lazy(() => import("./pages/IndexPage")); // 追加
const UsersPage = lazy(() => import("./pages/UsersPage")); // 追加
const ItemsPage = lazy(() => import("./pages/ItemsPage")); // 追加

const theme = createTheme();

export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Box sx={{ display: "flex" }}>
        <CssBaseline />
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<Layout />}>
              <Route index element={<IndexPage />} />
              <Route path="/users" element={<UsersPage />} />
              <Route path="/items" element={<ItemsPage />} />
            </Route>
          </Routes>
        </BrowserRouter>
      </Box>
    </ThemeProvider>
  );
}

これで完成です。

動かしてみると、各ページの表示前に一瞬だけ「Loading...」の文字が見えます。

Loadingが出る

今回は各ページの実装サイズが小さいため、さほど嬉しくはありませんが、各ページの依存ライブラリが大きくなってくると嬉しい挙動になることでしょう。

まとめ

というわけで、レイアウトの共通化とレンダリング省力化で意義のある <Outlet> と、そこにコード分割を組み合わせる方法について解説しました。

おそらく Remix 等で活用されるうちに、もう少し良い形も見つかるかと思いますが、ひとまずアイデアとしてはアリだと思うので、気が向いたら使ってみてください。

最後に、今回のコードは codesandbox に動く形でおいてあります。ご参考までにどうぞ。

宣伝

株式会社モニクルでは、はたらく世代・子育て世代がお金の不安を手放せる手助けをするための金融サービス業をより広めるために、ソフトウェアエンジニアを募集しています。

90 秒だけお時間をいただいて、↓ の動画を見ていただけると、どんなサービスをやっている会社なのかざっくりわかってもらえると思います。

https://www.youtube.com/watch?v=mwZ_tnGKEIY

マネイロにお金の相談をしにきてくださるだけでも嬉しいですし、もしこの記事を読んで会社自体にも興味を持っていただけたら、↓ の Culture Deck(会社説明資料)を読んでみてください。

https://speakerdeck.com/monicle/about

さらに興味を持ってもらえた方は Meety で私と雑談しましょう!

https://meety.net/matches/egOQbaoKmgHd

よろしくお願いします!

株式会社モニクル

Discussion