1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsでCRUD処理を実装するサンプルコード

Posted at

完成画面

今回の画面は、人気技術ブログ「R Effect」さんの「Next.js 14でStableになったServer ActionsでCRUD操作」の記事を参考にして、少しUIリッチな感じにしたアプリです。

Next.jsでフロントからサーバまでの処理を一気通貫したサンプルを作っていきます。

技術スタック

■Next.js
■Tailwind CSS
■Typescript
■Prisma
■MySQL

一覧画面

テーブル一覧.png

新規追加画面

新規追加.png

詳細画面

詳細画面.png

更新用の画面

更新画面.png

1行削除後の画面

削除後の画面.png

ディレクトリ構成

プロジェクトの構成は、下記のとおりです。
ディレクトリ構成.png

データベースとPrismaのスキーマ構成

テーブルの構成は、下記のとおりです。

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Todo{
  id Int @id @default(autoincrement())
  name String
  isCompleted Boolean @default(false)
}

Prismaスキーマをデータベースへ反映する。

作成したスキーマをテーブルへマイグレーションするためのファイルを作成しましょう。
プロジェクト直下に「lib」フォルダを作成して、「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;

一覧画面を作成する。

appディレクトリにサブフォルダ「todos」を作成して、todosフォルダの配下に「Page.tsx」ファイルを作成します。

app/todos/Page.tsx
import prisma from '@/lib/prisma';
import Link from 'next/link';
import DeleteButton from '@/components/delete-button';

const Page = async()=>{
    const todos = await prisma.todo.findMany();
    return(
        <div className='m-8'>
            <h1 className='text-x1 font-bold'>Todo一覧</h1>
            <Link href="/todos/create" 
            className='bg-blue-600 px-2 py-1 rounded-lg text-sm text-white'>新規追加</Link>
            <table className='border-collapse border border-slate-400'>
                <th scope='col' className='border border-slate-300 bg-gray-300 font-mono'>ID</th>
                <th scope='col' className='border border-slate-300 bg-gray-300 font-mono'>名前</th>
                <th scope='col' className='border border-slate-300 bg-gray-300 font-mono'>詳細</th>
                <th scope='col' className='border border-slate-300 bg-gray-300 font-mono'>更新</th>
                <th scope='col' className='border border-slate-300 bg-gray-300 font-mono'>削除</th>
                {todos.map((todo)=>(
                    <tr key={todo.id}>
                        <td scope='col' className='border border-slate-300 font-mono'>{todo.id}</td> 
                        <td scope='col' className='border border-slate-300 font-mono'>{todo.name}</td>  
                        <td scope='col' className='border border-slate-300'>
                            <Link href={`/todos/${todo.id}`} className='bg-blue-600 text-white rounded-sm font-mono'>
                                詳細
                            </Link>
                        </td>
                        <td scope='col' className='border border-slate-300'>
                            <Link href={`/todos/${todo.id}/edit`} className='bg-orange-600 text-white rounded-sm font-mono'>
                                更新
                            </Link>
                        </td>  
                        <td scope='col' className='border border-slate-300'>
                            <DeleteButton id={todo.id}/>
                        </td>             
                    </tr>
                ))}
            </table>
        </div>
    )
}

export default Page;

新規追加画面を作成する。

appディレクトリは以下に、サブフォルダ「create」を作成します。
「create」フォルダ配下に、「Page.tsx」を作成します。

app/create/Page.tsx
import React from 'react'; //インポートしておく
import Link from 'next/link';
import CreateForm from '@/components/create-form';

const Page = async () => {
  
  return (
    <div className="m-8">
      <Link href="/todos">戻る</Link>
      <h1 className="text-xl font-bold">Todo追加</h1>
      <CreateForm/>
    </div>
  );
};

export default Page;

新規データを登録するためのサーバ処理を作成する

新規データをデータベースに登録するために、appしれく鳥は以下にサブフォルダ「api」を作ります。
次にapiディレクトリ直下に「todos」フォルダを作成します。
つづいて、todosフォルダ配下に「route.ts」を作成します。

app/api/todos/route.ts
export async function POST(request:Request){
    const {name} = await request.json();
    await prisma.todo.create({data:{name}});
    return Response.json({message:'success'});
}

CRUD処理に関する処理を追加する。

プロジェクト直下にフォルダ「lib」を作成します。
「lib」フォルダにフロントからサーバ間のデータ受け渡し処理を記述する「action.ts」を作成します。

lib/actions.ts
'use server';
import { revalidatePath } from "next/cache";
import { redirect } from 'next/navigation';
import {z} from 'zod';

//Zodライブラリによりバリデーション処理
const schema = z.object({
    name:z.string().min(2),
});

//新規追加処理
export const addTodo = async(prevState:any,data:FormData)=>{
    const name = data.get('name') as string;
    const validatedFields = schema.safeParse({name});

    //追加データをJSONデータで出力
    console.log(JSON.stringify(validatedFields,null,2));

    //入力項目値のバリデーションチェック
    if(!validatedFields.success){
        return {
            erros:validatedFields.error.flatten().fieldErrors,
        }
    }

    try{
        //データ登録時に0.1秒のプロミス処理
        await new Promise((resolve)=>setTimeout(resolve,100));
        //データ登録
        await prisma.todo.create({data:{name}});
    }catch(e){
        return {
            message:'Failed to add'
        }
    }
    
    revalidatePath('/todos');
    redirect('/todos');
};

//削除処理
export const deleteTodo = async(id:number)=>{

    await prisma.todo.delete({
        where:{
            id,
        }
    });
    revalidatePath('/todos');
    
};

//更新処理
export const updateTodo = async (id:number,data:FormData)=>{
    const name = data.get('name') as string;
    const isCompleted = data.get('isCompleted') as string;
    await prisma.todo.update({
        where:{
            id,
        },
        data:{
            name,
            isCompleted:isCompleted === 'true' ? true:false,
        }
    });
    revalidatePath('/todos');
    redirect('/todos');
}

詳細画面を作成する

appディレクトリ直下の「todos」フォルダの移動します。
「todos」フォルダの直下に「Page.tsx」を作成します。

app/todos/page.tsx
import Link from "next/link"
export default async function Page({params}:{params:{id:string}}){
    const id = Number(params.id);
    const todo = await prisma.todo.findUnique({
        where:{id,}
    });

    return (
        <div className="m-8">
            <Link href="/todos" className='font-medium text-blue-600 dark:text-blue-500 hover:underline'>戻る</Link>
            <h1 className="text-x1 font-bold">Todo詳細</h1>
            <div>Id:{todo?.id}</div>
            <div>名前:{todo?.name}</div>
            <div>
                完了:{todo?.isCompleted ? <span>完了</span>:<span>未完了</span>}
            </div>
        </div>
    )
}

更新用の画面を作成する

appディレクトリ直下の「todos」フォルダの移動します。
「todos」フォルダの直下に「[ID]」フォルダを作成します。
「[ID]」フォルダに移動して「edit」フォルダを作りましょう。
「edit」フォルダに移動して、「Page.tsx」を作成します。

app/todos/[ID]/edit/Page.tsx
import {updateTodo} from '@/lib/actions';
import Link from 'next/link';

export default async function Page({params}:{params:{id:string}}){
    const id = Number(params.id);
    const updateTodoWithId = updateTodo.bind(null,id);
    const todo = await prisma.todo.findUnique({
        where:{
            id,
        }
    });

    return (
        <div className='m-8'>
            <Link href="/todos" className='font-medium text-blue-600 dark:text-blue-500 hover:underline'>戻る</Link>
            <h1 className='text-x1 font-bold'>Todo更新</h1>
            <form action={updateTodoWithId} className='mt-4'>
                <div className='display:inline-block'>
                    <label htmlFor='name'>Name:</label>
                    <input
                        type="text"
                        name="name"
                        className='border mx-2 p-1'
                        defaultValue={todo?.name}
                    />
                </div>
                <div className='display:inline-block'>
                    <input
                        name='isCompleted'
                        type='radio'
                        value="true"
                        defaultChecked={todo?.isCompleted === true}
                    />
                    <label htmlFor='isCompleted'>完了</label>
                </div>
                <div className='display:inline-block'>
                    <input
                        name='isCompleted'
                        type='radio'
                        value="false"
                        defaultChecked={todo?.isCompleted === false}
                    />
                    <label htmlFor='isCompleted'>未完了</label>
                </div>
                <button
                    type='submit'
                    className='mt-4 bg-blue-800 py-4 py-1 rounded-lg text-sm text-white'
                >
                    内容を更新する
                </button>
            </form>
        </div>
    )
}




CRUDのモジュール分割

コードの見やすさやメンテナンス性を高めるために、モジュールを分割してみます。

分割した「モジュールの配置場所は、プロジェクトの直下に「components」フォルダを作り、このフォルダに
CRUD系の処理を配置します。

components/create-from.tsx
'use client';

import { useFormState } from "react-dom";
import {addTodo} from '@/lib/actions';
import { SubmitButton } from "./submit-button";

const initialState = {
    message:null,
    erros:{},
};

const CreateForm = ()=>{
    const [state,formAction] = useFormState(addTodo,initialState);
    return (
        <form className="flex items-center mt-4" action={formAction}>
            <label htmlFor="name">Name:</label>
            <input type="text" name="name" className="border mx-2 p-1" />
            {state?.message && (
                <div className="text-red-600 font-bold my-2">
                    {state?.message}
                </div>
            )}
            {state?.erros?.name && state.erros.name.map((erros:string)=>(
                <div className="text-red-600 font-bold my-2" key={erros}>
                    {erros}
                </div>
            ))}
            <SubmitButton />
      </form>
    )
}

export default CreateForm;
components/delete-button.tsx
import { deleteTodo } from "@/lib/actions";

const DeleteButton = ({id}:{id:number})=>{
    const deleteTodoWithId = deleteTodo.bind(null,id);
    return (
        <form action={deleteTodoWithId}>
            <button className="bg-red-500 px-2 py-1 rounded-lg text-sm text-white">
                削除
            </button>
        </form>
    )
}
export default DeleteButton;
components/form.tsx
'use client';
import { addTodo } from '@/lib/actions';
import { useState } from 'react';

const Form = ()=>{
    const [name,setName] = useState('');
    const handleChange = (e:React.ChangeEvent<HTMLInputElement>)=>{
        setName(e.target.value);
    };
    const handleSubmit = async(e:React.FormEvent<HTMLFormElement>)=>{
        e.preventDefault();
        await addTodo(name);
    };

    return (
        <form className="flex items-center mt-4" onSubmit={handleSubmit}>
            <label htmlFor='name'>Name:</label>
            <input
                id="name"
                name="name"
                value={name}
                onChange={handleChange}
                className="border mx-2 p-1"
            />
            <button
                type="submit"
                className="bg-blue-600 px-2 py-1 rounded-lg text-sm text-white"
            >
                Add Todo
            </button>
        </form>
    )
}

export default Form;
components/submit-button.tsx
'use client';

import { useFormState } from "react-dom";

export function SubmitButton(){
    const {pending} = useFormState();

    return (
        <button
            type="submit"
            disabled={pending}
            className={`bg-blue-600 px-2 py-1 rounded-lg text-sm text-white
                ${pending ? 'opacity-50':''}`}
        >
            Add Todo
        </button>
    )
}



以上となります。
ありがとうございました。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?