📝

App Routerのlayout.jsはasyncにしない方が良いかも

2023/12/15に公開

挙動を理解して使うならasyncにしてもOK。

対象読者

  • Next.jsのApp Routerを使っている人
  • layout.jsをRSCで実装している人

結論

layout.jsをasync functionにすると、一つ上の階層のloading.jsが画面に表示されます。

app/
└── parent/
    ├── loading.js
    ├── layout.js
    ├── page.js
    └── children/
        ├── loading.js
        ├── layout.js  <- これがasync関数
        └── page.js

app/parent/page.jsからapp/parent/children/page.jsに画面遷移する場合、以下の順番で画面が表示されます。

  1. app/parent/loading.js
  2. app/parent/children/layout.js
  3. app/parent/children/loading.js
  4. app/parent/children/page.js

2, 3, 4は直感的ですが、1が意外でした。
app/parent/children/layout.jsasyncにしなければ1は表示されなくなります。

なぜこのような挙動になるのか

サンプルコード等はTypescriptで記述します。
Next.jsの公式も目を通すと良いと思います。

APIから情報を取得しつつ、<Header />を組み立てるLayoutです。

app/parent/children/layout.tsx
export default function async Layout({ children }: { children: React.ReactNode }) {
  const response = await fetch("/api");
  const data = await response.json();
  return <Header data={data}>{children}</Header>
}

このようにasync関数でlayout.tsxを記述した場合、app/parent/loading.tsxが表示されます。
公式ページでこの仕様が明記されているのは確認できませんでしたが、推測のヒントが書かれていました。

{/* app/parent/layout.tsx */}
<ParentLayout>
  <SomeComponent />
  {/* app/parent/loading.tsx */}
  <Suspense fallback={<ParentLoading />}>
    {/* app/parent/page.tsx */}
    <ParentPage />
  </Suspense>
</Layout>

Next.jsのファイル配置はこのように処理されています。
では、childrenディレクトリ以下を含めたらどうなるでしょうか。
App Routerのlayout.tsxの仕様である、上位ディレクトリのlayout.tsxは常に有効がヒントになります。

{/* app/parent/layout.tsx */}
<ParentLayout>
  <SomeComponent />
  {/* app/parent/loading.tsx */}
  <Suspense fallback={<ParentLoading />}>
    {/* app/parent/children/layout.tsx */}
    <ChildrenLayout>
      <Header />
      {/* app/parent/children/loading.tsx */}
      <Suspense fallback={<ChildrenLoading />}>
        {/* app/parent/children/page.tsx */}
        <ChildrenPage />
      </Suspense>
    </ChildrenLayout>
  </Suspense>
</Layout>

この構造を見たら気づくかもしれませんが、<ChildrenLayout />でpromiseがthrowされると、上位の<Suspense />のfallbackが表示されます。
つまり、app/parent/children/layout.tsxをasync関数で実装すると、app/parent/loading.tsxのfallbackに入るということです。

言われてみれば確かにそうなのですが、実装しているとなぜか上位ディレクトリのloading.tsxが急に表示されてびっくりしました。

どうすれば良いのか

layout.tsxをasync関数で実装しなければ解決です。

app/parent/children/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <Suspense fallback={<SomeLoading />}>
      <LayoutContents>{children}</LayoutContents>
    </Suspense>
  );
}

async function LayoutContents({ children }: { children: React.ReactNode }) {
  const response = await fetch("/api");
  const data = await response.json();
  return <Header data={data}>{children}</Header>
}

こうすればapp/parent/children/layout.tsxがpromiseをthrowしなくなるので、上位階層のloading.tsxが表示されなくなります。

最後に

ソースコードまで追っかけたわけじゃないので的外れなことを言っていたら指摘していただけると助かります。

Discussion