🔎

React (SPA) の感覚から Next.js の感覚に切り替える話

2024/07/22に公開

Todoアプリ作成を題材に React SPA と Next.js の
データ取得周りの違いをまとめます

React (SPA) + Web API (Python, Flask) + MySQL の3層構成Webアプリケーションを作っていたのですが、Next.jsを勉強し始めるとSPAとだいぶ感覚が異なると感じました

独学でNext.jsのドキュメント等眺めていたのですが、SPAの感覚が抜けずにNext.jsらしくないWebアプリケーションを作っていました

もちろんNext.jsを使ってもSPA方式のWebアプリケーションを作れますし、実装方法に唯一の正解は無いとは思うのですが、Next.js公式のドキュメンテーションにもある 「サーバ↔クライアント間のデータの往復回数を減らす」ことを意識して実装すると、コード量やライブラリへの依存性を減らしたNext.jsらしい形にできそう な感覚を持つに至りました。
公式ドキュメントのどの辺に記述が有ったか正確に思い出せていませんが...このへんでしょうか...

この記事はReactによるSPAとNext.js的な構成とのどちらが良いか決めようというものではなく、
データ取得の仕方が全然違うので、片方しか知らない人にとっては結構ギャップがあるかもしれませんね、という程度のものです

SPA的なデータ取得のやりとり

SPA的なWebアプリケーションでは、

  1. SPA自体のデータのロード
  2. ユーザ毎のデータのロード

の少なくとも2回のHTTP通信を経てユーザ毎のデータが画面に表示されます(下図)。
(SPA全体が1回目で読み込まれているので、その後の動作は高速であると言われます)

ユーザ毎のデータのロードでは useSWRtRPC 等を使うですとか、もっとプリミティブに useEffect + axios or fetch 等々、様々な選択肢が有ります。
Client Sideでのデータ取得+変更は、Reactを使ったWebアプリケーション作成において避けては通れない重要な要素です

Next.js的なデータ取得のやりとり

Next.js的なWebアプリケーションでは、基本的に1度のHTTP通信でユーザ毎のデータを表示します。
(ただしページ遷移も前提にした、アプリケーション全体が一度に読み込まれない構成がスタンダードなので、SPAよりその後の動作は遅いと言われます)

Next.jsがServer Sideでユーザ毎のデータ取得とHTML+JavaScript生成を行います。
Client Sideでデータ取得を行うためのライブラリが無くても良いことになります。
データの更新にはServer Actionsという機能を用いる方法がチュートリアル化されています。

データ取得と変更の例

ReactによるSPAの例を前置きとして、Next.jsの場合がどうなるかを本題としてコードを例示してみます。
本当はユーザ入力をdebounceしたい等、実用上の問題はあるコードですが、「あー、データ取得周りのコードが大分違いそうだなー」という感覚を持って頂ければと思います。

前置き:ReactによるSPA (useSWR + fetch API)

ToDoリストをuseSWRで取得し、個々のtaskの更新時にはすべてのタスクをまとめて再ロードする場合を考えてみます。

TaskList.tsx
import React from 'react';
import useSWR from 'swr';
import { Task } from '@/types';

const TaskList: React.FC = () => {
  // tasks は Task[] | undfined 型になる
  // でも'/api/tasks'が返すJSONの型もそうなっているとは限らないので注意
  // Zod等のバリデーション用のライブラリでチェックする方法もあります
  //
  // Web API エンドポイントの実装は省略します...
  const { data: tasks, mutate } = useSWR<Task[]>(
    '/api/tasks',
    (url: string) => fetch(url).then(r => r.json())
  );

  const updateTask = async (newTask: Task) => {
    await fetch(
      `/api/task/${task.id}',
      { method: 'POST', body: JSON.stringify(newTask) }
    );
    // tasksを再ロードする
    await mutate();
  };

  return (
    <>
      {tasks?.map(task =>
        <TaskItem key={task.id} task={task} update={updateTask}/>
      )}
    </>
  );
};
TaskItem.tsx
import React from 'react';
import { Task } from '@/types';

type TaskItemPops = {
  task: Task;
  update: (newTask: Task) => void;
}

const TaskItem: React.FC<TaskItemProps> = ({
  task,
  update,
}) => (
  <input
    defaultValue={task.content}
    onChange={e => update({ ...task, content: e.target.value }) }
  />
);

本題:Next.js の場合

データ処理の流れがSPAの場合とだいぶ異なります。
そこじゃないと言われそうですが、個人的にはServer Actionsによる型安全なデータ取得が追加の仕組み無しで実現されているところがすごく好きです。

サーバ側とクライアント側のコードをいっぺんに書けるので、サーバ上にある見せたくないデータが送信されていないか?等、気を遣うところもありますが、面白くて便利な仕組みだと思います。

TaskList.tsx
// Next.jsでは'use client'を付けないコンポーネントツリーは
// サーバー側でレンダリングされます

import React from 'react';

// Task[]を取得する関数、Server Actionsっぽいディレクトリに入れましたが
// 普通のasync関数で事足ります
import { getTasks } from '@/actions/tasks';

// Server Componentはasync関数にできます
const TaskList: React.FC = async () => {
  const tasks = await getTasks();
  return (
    <>
      {tasks.map(task =>
        <TaskItem key={task.id} task={task} />
      )}
    </>
  );
};
TaskItem.tsx
// onChange を使用したいのでClient Componentとします
'use client'

import { Task } from '@/types';

// タスク内容を更新するServer Action
import { updateTask } from '@/actions/tasks';

type TaskItemProps = {
  task: Task;
}

const TaskItem: React.FC<TaskItemProps> = ({ task }) => (
  <input
    defaultValue={task.content}
    onChange={async e => await updateTask({
      ...task, content: e.target.value
    })}
  />
);
@/actions/tasks.ts
// このファイルはServer Actionsを定義しています
// クライアント側のform actionやイベントハンドラから呼ばれると
// POSTリクエストに自動的に変換されて通信が行われます
'use server'

// データベースにアクセスするためのTypeScriptライブラリは色々ありますが
// Drizzle ORMを例に用います
import { tasks } from '@/db/schema';
import { db } from '@/db';
import { eq, and } from 'drizzle-orm';

// ログイン情報を取得するための、Auth.jsを使った機能です
// 一般的な内容や使い方は公式ドキュメントを参照ください...
import { auth } from '@/auth';

// ログイン済みならユーザIDを返し、そうでないなら例外とします
// Auth.jsデフォルトのUser型に合わせていますが、
// 自分で定義することも可能です
async function getUserId() {
  const session = await auth();
  if (session?.user?.name == null) {
    throw new Error('ログイン情報を取得できません');
  }
  return session.user.name;
}

// タスクを取得するためのServer Action
export async function getTasks() {
  const userId = await getUser();
  return await db
    .select()
    .from(tasks)
    .where(eq(tasks.userId, userId));
}

// タスクを更新するためのServer Action
export async function updateTask(newTask: Task) {
  const userIdFromSession = await getUser();
  const { id, useId: _doNotUseUserIdFromClient, ...params } = newTask;
  // クライアントからのPOSTリクエストで送られてくるuserIdは信頼できるとは限らないので、
  // 暗号化されたセッションに保存されたuserIdのみ使用するようにします
  await db
    .update(tasks)
    .set({ ...params })
    .where(
      and(
        eq(tasks.id, id),
        eq(tasks.userId, userIdFromSession)
      )
    );
}

サーバ側でデータを取得し、それを用いてコンポーネントを構築する流れになっています。

コード例について

コード例はNext.jsを動作させるのに必要な記述を網羅できていません。
開発中のこちらのリポジトリに、上記の流れに沿ったコードがあります。
https://github.com/Daiius/sekirei-todo

結論

Static Renderingになるページを増やす方が良いのだろうか...とか、
Next.jsとはいえWeb API用のエンドポイントは必要なのではないだろうか...とか、
色々考えたのですが、他に制約が無ければ「HTTP通信の往復を最低限にする」という方針で自然なNext.jsアプリケーションを書けるのではないかと思います。

それでは、良いWebアプリケーション開発を!

Discussion