3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoQSystemAdvent Calendar 2023

Day 7

App Routerを使用したTodoアプリを作成してみる

Last updated at Posted at 2023-12-07

Next.js界隈ではApp Routerを使用したアプリケーション開発が話題になっています。
App RouterはNext.js v13.4から安定版としてリリースされた機能です。RSCの実装から特別な名前を持ったディレクトリ・ファイル名など、多くの追加がなされました。

App Routerの理解を深めるため、Todoアプリを作成していきます。

インフラ構築でDockerとPostgresDBを使用していますが、ここでは詳しく言及しません。
各自で環境構築をお願いします。

アプリケーション概要

仕様

以下の実装を目指します。

  • todoの追加
  • todoの削除
  • todoの完了状態更新

画面収録-2023-12-05-11.47.36.gif

技術スタック

# Frontend
next 14.0.0
@mantine/core ^7.1.7,
prisma ^5.5.2
typescript ^5

# Infrastructure
postgres 14.2
docker 3.9

事前知識

先にコード内で使用される機能について簡単に説明します。

App Router

Next.js v13で追加されたルーター方式です。
appディレクトリ下に配置される全てのコンポーネントは、デフォルトでサーバーサイドレンダリングが行われるようになります。

React Server Components(以降、RSCと略します)

Reactアプリのパフォーマンスを向上させるために開発された方法です。クライアントかサーバーかどちらでレンダリングするか分けることができます。
Reactコンポーネントをサーバーサイドでレンダリングすることで高速なデータ取得が可能となり、リクエスト数の削減が見込めます。

ルーティング

App Routerではファイル名が意味を持ちます。
例えば、page.tsxを配置することでページ定義を行うことができます。todoディレクトリ下に配置されるとhttp://localhost:3000/todo で表示されるようになるわけです。

その他、layout.tsxerror.tsxも特別な意味を持つようになります。

Private Folders

app配下ディレクトリは全てルーティング対象となります。
そこでPrivate Foldersを使用することでルーティング対象から外すことができます。

適応は簡単で、該当ディレクトリ名の先頭に_を指定することで可能です。

'use client'

通常サーバーサイドレンダリングされますが、イベントリスナーやサーバーサイドに対応していないライブラリなどでは動きません。
そこで明示的にuse clientと書き加えることで、クライアントサイドレンダリングを宣言することができます。

フォームアクション

formのactionに非同期関数を設定することで、サーバーサイド処理を実行可能です。
指定した非同期関数の引数にはFormDataが渡ってきます。

revalidatePath

キャッシュ制御関数であり、引数に関連づけたパスを指定することで再検証を待たずしてキャッシュ更新することができます。

アプリケーション実装

ここではインフラ構築・フロントエンド実装の2部構成で進めていきます。

インフラ構築

Dockerを使用した構築になります。コード全体を載せておきます。

docker-compose.yaml
version: "3.9"
services:
  db:
    image: postgres:14.2
    volumes:
      - ./db:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: {ユーザー名}
      POSTGRES_PASSWORD: {パスワード}
      POSTGRES_DB: {DB名}
    ports:
      - "5432:5432"

あとはコンテナを立てれば完了です。

# バックグラウンド実行
docker compose up -d

フロントエンド実装

ディレクトリ

/src
    |- app
        |- _features
            |- todo
                |- actions
                    |- todo.ts
                |- components
                    |- AddTodo.tsx
                    |- TodoList.tsx
        |- _libs
        |- layout.tsx
        |- page.tsx

appディレクトリ内に全て格納した形になります。
ディレクトリ名の接頭尾に_がついていますが、Private Foldersを使用してルーティング対象から外しています。

prisma.ts

_lib/prisma.ts
declare global {
  var prisma: PrismaClient;
}

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;

prismaライブラリの初期設定を行なっています。

page.tsx

app/page.tsx
import prisma from '@/_lib/prisma';
import { Title, Container, Space } from '@mantine/core';

import TodoList from '@/_features/todo/components/TodoList';
import AddTodo from '@/_features/todo/components/AddTodo';

const Home = async () => {
  const todos = await prisma.todo.findMany({
    orderBy: {
      id: 'asc',
    }
  });

  return (
    <Container py={24}>
      <Title order={1} mb="md">AppRouter+Prisma Todoアプリ</Title>
      <AddTodo />
      <Space h={32} />
      { todos.length !== 0 && (
        <TodoList todos={todos} />
      )}
    </Container>
  )
}

export default Home;

RSCではデフォルトでサーバーサイドレンダリングするため、getServerSidePropsを使用せずともデータの取得が可能になっています。

アクション関数

実際にデータのやり取りをする部分を関数に切り出して使用します。

app/_features/actions/todo.ts
'use server'

import prisma from '@/_lib/prisma';
import { revalidatePath } from 'next/cache';

export const addTodo = async (data: FormData) => {
  const todo = data.get('todo') as string;
  await prisma.todo.create({ data: { title: todo } });
  revalidatePath('/');
}

export const deleteTodo = async (data: FormData) => {
  const id = data.get('id') as string;
  await prisma.todo.delete({
    where: {
      id: +id,
    },
  });
  revalidatePath('/');
};

export async function doneTodo(id: number, published: boolean) {
  await prisma.todo.update({
    where: {
      id: +id,
    },
    data: {
      published: !published,
    },
  });
  revalidatePath('/');
}

prismaはクライアント側では操作できません。サーバーサイドで実行する必要があります。
そこで最初に'use server'と宣言することでサーバーサイドで実行されるようになるわけです。

また、追加や削除などのアクションのあとはキャッシュ更新し最新のデータを表示させたいのでrevalidatePath('/')としています。

TodoList.tsx

app/_features/todo/components/TodoList.tsx
'use client'

import { type FC } from 'react'
import { Table, Button, Checkbox } from '@mantine/core';

import { deleteTodo, doneTodo } from '../actions/todo';
import type { Todo } from '@prisma/client';

type Props = {
  todos: Todo[]
}

const List: FC<Props> = ({ todos }) => {
  return (
    <Table striped>
      <Table.Thead>
        <Table.Tr>
          <Table.Th>内容</Table.Th>
          <Table.Th>&ensp;</Table.Th>
        </Table.Tr>
      </Table.Thead>
      <Table.Tbody>
        {todos.map((todo) => (
          <Table.Tr key={todo.id}>
            <Table.Td width="100%">
              <Checkbox
                checked={todo.published}
                label={todo.title}
                styles={{ root: { wordBreak: 'break-all' } }}
                onChange={() => doneTodo(todo.id, todo.published)}
              />
            </Table.Td>
            <Table.Td>
              <form action={deleteTodo}>
                <input type="hidden" name="id" value={todo.id} />
                <Button
                  variant='outline'
                  color='red'
                  size="xs"
                  type='submit'
                  styles={{
                    root: {
                      whiteSpace: 'nowrap',
                    }
                  }}
                >
                  削除
                </Button>
              </form>
            </Table.Td>
          </Table.Tr>
        ))}
      </Table.Tbody>
    </Table>
  )
}

export default List;

データを表示させるコンポーネントです。表示以外でも削除とステータス更新も担っています。

ここではcheckboxのonChange関数が設定されています。クライアントサイドのインタラクティビティを実現するためにuse clientが必要になります。

AddTodo.tsx

app/_features/todo/components/AddTodo.tsx
'use client'

import { addTodo } from '@/_features/todo/actions/todo';
import { TextInput, Button } from '@mantine/core';
import { useRef } from 'react';

const AddTodo = () => {
  const formRef = useRef<HTMLFormElement>(null);
  const insertTodo = async (data: FormData) => {
    const todo = `${data.get('todo')}`;
    if (!todo) return;
    if (!todo.trim()) return;

    await addTodo(data);
    if (formRef.current) formRef.current.reset();
  };

  return (
    <form action={insertTodo} ref={formRef}>
      <TextInput name="todo" label="TODO" description="Please write your todo task." placeholder="shopping" mb={16} required />
      <Button type="submit" variant="filled">Add Todo</Button>
    </form>
  );
};

export default AddTodo;

todoを登録するためのフォームコンポーネントになります。
useRefを使用していますので、クライアントレンダリングをする必要があります。

フォームアクションを使用しており、APIエンドポイントを使用せずともデータ操作を行うことができます。

App Routerの使用感

メリットは以下の通りです。

  • RSCによって、データの取得が容易になった。
  • API Routeを使用しなくてもフォームアクションからサーバーサイド処理が行えるようになって楽になった。

デメリットは以下の通りです。

  • サーバーサイドとクライアントサイドの区別がつけにくい。
  • 使うライブラリによってサーバーサイドでは使えないものもあるので、導入には注意が必要。

まとめ

App Routerを使用したTodoアプリを実装しましたが、ある程度の慣れが必要だと感じました。
導入自体は簡単ですが、前段階のコンポーネント設計などしっかりしなければ思わぬところでエラーが発生する可能性があります。

また、今回出てきた機能は一握りです。
公式ではルーティング、データフェッチ、キャッシュなど深い部分まで説明されているので、ぜひ一読することをお勧めします。

最後に

GoQSystemでは一緒に働いてくれる仲間を募集中です!

求人は出していませんが、SREやQAエンジニアも今後募集していこうと考えています!
ご興味がある方は以下リンクよりご確認ください。

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?