初めに
10月後半に行われたNext.js Conf 2022で発表されたNext.js 13を実際に触ってみたのでその内容を書きます。
当初は全体を網羅して書く予定だったのですが量が多かったの少し絞ってます。
対象読者としてある程度ReactやNext.jsを触っている人を対象としています。
公式ドキュメント参考に行います
https://beta.nextjs.org/docs/getting-started
今回使用したサンプルリポジトリ
https://github.com/happy663/Next13-sample
プロジェクト作成からサーバ起動
TypeScriptとESLintを入れるか聞かれるので入れる
npx create-next-app@latest --ts
pagesディレクトリを削除
rm -rf pages
appディレクトリを作る
mkdir app
appディレクトリは実験段階の機能なので、next.config.jsを変更する
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
appDir: true,
},
};
app/page.tsxを作り、以下のようにする
export default function Page() {
return <h1>Hello, Next.js!</h1>;
}
このpage.tsxがアプリケーションのルートにアクセスされた時にレンダリングされるページになります。
Next.js 13以前のpagesディレクトリのindex.tsxに相当するところですね。
ローカルサーバ起動
npm run dev
ブラウザで http://localhost:3000/ にアクセスすると、Hello, Next.js! が表示される
LayoutとHead
サーバを起動すると自動的にappディレクトリにlayout.tsx,head.tsxというファイルが作られていました。
next/headを使って各ページファイルにheadを定義していたのがhead.tsxに書けるようになったみたいですね
export default function Head() {
return (
<>
<title></title>
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link rel="icon" href="/favicon.ico" />
</>
)
}
ページの共通レイアウトを定義するファイル
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}
今までは_app.tsxなどに下記のようにレイアウトを定義していました
<Layout>
<Component {...pageProps} />
</Layout>
この方法だとページごとにレイアウトを変えることができません。なのでページごとにレイアウトを変えたい場合はgetLayoutを用いる必要がありました。Next.js 13ではレイアウトを変えたいページがあるディレクトリにlayout.tsxを置くことでページごとにレイアウトを変えることができるようになりました。
ここではダッシュボードページのレイアウトの場合を考えます。
appディレクトリの中でdashbordディレクトリを作成して以下のようにlayout.tsxを配置するだけです。
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <section>{children}</section>;
}
少し疑問に思ったのがdashboardディレクトリのpage.tsxではRootLayoutは呼ばれずDashboardLayoutのみが呼ばれるのかと思っていました。しかしそんなことはなく入れ子構造で呼び出されるようです。
次の機能に行く前にdashboardディレクトリにpage.tsxも作っておきます
export default function DashBoardPage() {
return <h1>DashBoard Page</h1>;
}
違いがわかりやすいように他のファイルも変更します
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head />
<body
style={{
backgroundColor: '#C0C0C0',
padding: '50px',
}}
>
{children}
</body>
</html>
);
}
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section
style={{
backgroundColor: 'white',
}}
>
{children}
</section>
);
}
React Server Components
React Server Components(RSC)はReact18で追加された機能でクライアントとサーバ側が協調してアプリケーションをレンダリングできる機能です。これによりコンポーネントごとに最適なレンダリング方法を選択できるようになります。例えばデータの取得はサーバ側で行い、ユーザの操作によって変わる部分はクライアント側でレンダリングするといったことができます。
これも公式ドキュメントの図がわかりやすいので貼っておきます。
またSSRとの違いとしてクライアント側のJavaScriptの量を減らせる点です。
SSRの場合ハイドレーション(サーバ側で生成したDOMとクライアントで生成したDOMを合成する)というステップがありページを早く表示できてもクライアント側でも同じ処理が走るためJavaScriptの量は同じでした。RSCはサーバ側でレンダリングした後残りをクライアント側でレンダリングします。これによってクライアント側に送信されるJavaScriptの量を減らすことができます。
サーバーコンポーネントでデータを取得
appディレクトリ内のコンポーネントはデフォルトだとサーバコンポーネントになっています。
以下のコードはサーバサイドでqiitaの記事リスト取得して表示するものです。
dashboard/page.tsxを書き換えます。
type Article = {
id: number;
title: string;
};
async function getArticle(): Promise<Article[]> {
const res = await fetch('https://qiita.com/api/v2/items?page=1&per_page=24');
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function DashBoardPage() {
const articles = await getArticle();
return (
<div>
<h1>Dashboard</h1>
<div
style={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
height: '50vh',
}}
>
{articles?.map((article) => (
<div
key={article.id}
style={{
display: 'flex',
gap: '10px',
}}
>
<p>{article.title}</p>
</div>
))}
</div>
</div>
);
}
サイトにカーソルを合わせて右クリックしてページのソースを表示をクリックしてみましょう。
あらかじめデータが入った状態でサーバから送られてくるのでページソースに記事データが表示されています。
ローディングのUI表示
次はローディングUIを表示する機能です
dashboradディレクトリにloading.tsxを作成します
export default function Loading() {
return <p>Loading...</p>;
}
データの取得が終わりpageコンポーネントがレンダリングされるまでの間はLoadingコンポーネントが表示されます
これはReact18で追加されたSuspenseという機能が使われていてます。
Suspenseについて説明するとコンポーネントが表示されるまでの状態を指定することができるコンポーネントです。非同期的なコンポーネントの場合レンダリングに時間がかかるためその間に何を表示させるかをSuspenseを使うと指定できるようになります。
具体的な使用例を上げます。下のコードはデータフェッチライブラリ React Queryを使ったデータ取得と表示のサンプルです。
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}
export function Loading() {
return <p>Loading...</p>;
}
function Example() {
const { isLoading, data } = useQuery('repoData', () =>
fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
(res) => res.json()
)
);
if (isLoading) return <Loading />;
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
</div>
);
}
Suspenseを使えば下のコードに置き換えることができます
import { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
{/* suspenseコンポーネントででラップ */}
<Suspense fallback={<Loading />}>
<Example />
</Suspense>
</QueryClientProvider>
);
}
export function Loading() {
return <p>Loading...</p>;
}
function Example() {
const { data } = useQuery('repoData', () =>
fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
(res) => res.json()
)
);
//ローディングプロパティによる表示分岐の削除
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
</div>
);
}
変更点はExampleコンポーネントをSuspenseコンポーネントでラップしているのと、Exampleコンポーネントの中のisLoadingプロパティを使った表示切替の部分が消えているところです。Suspenseのいいところはデータ取得をするコンポーネントの中で表示を切り替える処理を書く必要がないことです。これによってより宣言的なコードになりました。またコンポーネントの責務の観点から見てもローディング完了時の表示だけでよくシンプルになっています。
Next.js 13ではloading.tsxを置いてあげるとNext.js側がそれを読み取りPageコンポーネントをSuspenseコンポーネントでラップしてくれるようです。
普通に書くと以下のようになります。
<Layout>
<Headr/>
<SideNav/>
<Suspense fallback={<Loading />}>
<DashBoardPage />
</Suspense>
</Layout>
ディレクトリ内にloading.tsxを置いておけば自動的にこのコードを書いた動きになるということですね。
エラーハンドリング
次にエラーハンドリングを見ていきます。
dashboardディレクトリにerror.tsxを作成します。
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<p>Something went wrong!</p>
<button onClick={() => reset()}>Reset error boundary</button>
</div>
);
}
公式ドキュメントによるとerror.tsxはクライアントコンポーネントである必要がありuse client
;と書くことでクライアントコンポーネントして利用することができるようです。
loading.tsxと同じようにファイルを置いておけば自動的にPageをネストしてエラーハンドリングをしているようです。
普通に書いた場合
<Layout>
<Headr/>
<SideNav/>
<ErrorBoundary fallback={<Loading />}>
<DashBoardPage />
</ErrorBoundary>
</Layout>
ただこれを見てわかる通りpageをラップしてるので同階層のコンポーネントのエラーハンドリングはできない感じですね。したいならより上の階層でErrorBoundaryを使う必要がありそうです。
実際にコードを書き換えてエラーを出してみます。
存在しないURL'hoge'を指定しgetArticelでしていたエラーハンドリングを削除しています。
type Article = {
id: number;
title: string;
};
async function getArticle(): Promise<Article[]> {
const res = await fetch('hoge');
return res.json();
}
export default async function DashBoardPage() {
const articles = await getArticle();
return (
<div>
<h1>Dashboard</h1>
<div
style={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
height: '50vh',
}}
>
{articles?.map((article) => (
<div
key={article.id}
style={{
display: 'flex',
gap: '10px',
}}
>
<p>{article.title}</p>
</div>
))}
</div>
</div>
);
}
エラーの内容とエラーページが表示されていますね
終わりに
感想としてはNext.js 13の機能はReact18のSupenseやRSCなどの機能に合わせたアップデートというのを強く感じました