Next.js + TypescriptでミニCMSを作る（6. 記事の削除ボタン）

プライベートが忙しくなっていまい、更新頻度が空いてしまいましたね。
とりあえず書き溜めていた記事だけでも、投稿がんばります！

# 削除ボタンを作成します

:::note info
[前回](https://qiita.com/AndanteSysDes/items/a8b5af6c04c6a53f7970)の続きです。
:::

![レビュー一覧_削除ボタン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/917ac055-bd95-8c83-e7b7-8069c573ec36.png)

削除ボタンの機能を実装します。

## クライアントコンポーネントの特徴を利用する

今のコンポーネントの構造を再掲します。
![Component-Structure-ReviewList.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/2ec9734e-a535-3de9-aac9-012bac74b9c3.png)

`ReviewCardContainer`というクライアントコンポーネントが、レビュー記事の配列を親コンポーネントから受け取ってそのまま表示している状態です。
この状態でDB上のレビューデータを削除したとしても、親から渡されるPropsはページが更新でもされてサーバに再要求されない限りは、更新されずに古いデータのままになってしまいます。
削除ボタンを押すたびに更新処理をかけるのはUXの質の低下に繋がりかねません。

そこで、クライアントコンポーネントでのみ利用できる、Reactフック`useState`の出番です。

>state フック 
>state を使うと、ユーザの入力などの情報をコンポーネントに「記憶」させることができます。例えば、フォームコンポーネントは入力された文字を保持し、画像ギャラリのコンポーネントは選択された画像を保持できます。

https://ja.react.dev/reference/react/hooks#state-hooks

これを使って、Propsで渡ってきたレビュー記事の配列をStateに記憶させ、削除ボタンが押されたときは**DBへの削除命令とsetStateの２つの操作を同時に行う**ことにします。


### State操作

各コンポーネントを以下の通り更新します。


```diff_tsx:components/ReviewCardContainer/ReviewCardContainer.tsx
'use client'

import { ReviewWithProductId } from "@/app/types";
import styles from "./CardContainer.module.css";
import ReviewCard from "@/app/components/ReviewCard/ReviewCard";
+ import { useState } from "react";

type Props = {
  title: string;
  reviews: ReviewWithProductId[];
}

export default function ReviewCardContainer(props: Props) {

-  const reviews = props.reviews;
+  const [reviews, setReviews] = useState(props.reviews);

+  const handleDelete = async (review : ReviewWithProductId) => {
+    const answer = confirm(`レビュー記事「${review.title}」を削除しますか？`);
+    if(answer){
+        //update reviews
+        const newReviews = reviews.filter((e) =>  e.id !== review.id )
+        setReviews(
+          newReviews
+        )
+    }
+  }

  
  return ( 

    <div>
      <hr />
      <div className={styles.titleContainer}>
        <h2>{props.title}</h2>
        <p>{reviews.length}</p>
      </div>
      {reviews.length == 0 
      ?  (<p className={styles.noPost}>投稿はありません。</p>) 
      :  reviews.map((review, index) =>(
-          <ReviewCard key={index} review={review} />
+          <ReviewCard key={index} review={review} onDeleteReview={(review) => handleDelete(review)} />
        ))}
    </div>
  )
}
```




```diff_tsx:components/ReviewCard/ReviewCard.tsx
import Image from "next/image";
import styles from "./reviewCard.module.css";
import Link from "next/link";
import { ReviewWithProductId } from "@/app/types";

interface Props {
  review: ReviewWithProductId,
+  onDeleteReview: (review: ReviewWithProductId) => void,
}

- export default function ReviewCard({review} : Props) {
+ export default function ReviewCard({review, onDeleteReview } : Props) {

    return (

    //中略
    
                <div className={styles.buttonContainer}>
                    <Link href={`/dashboard/review/${review.id}`} className={styles.buttonLink}>
                        <Image src="/img/icon/awesome-edit.svg" width={20} height={20} alt="編集"/>
                    </Link>

-                   <button className={styles.deleteButton}>
+                   <button onClick={() => onDeleteReview(review)} className={styles.deleteButton}>
                        <Image src="/img/icon/material-delete.svg" width={20} height={20} alt="削除"/>
                    </button>

                </div>
    //中略
    )
}
```

これで、`ReviewCard`コンポーネント上のゴミ箱ボタンが押されると、親の`ReviewCardContainer`からPropsとして渡された`onDeleteReview`関数が呼び出されます。
引数となったreviewは配列から`filter()`関数を使って除去され、新しい配列がStateにセットされるというわけです。

この段階で、実際にNextのdev環境で削除ボタンを押すと、レビューが消えるのが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547786/10e663c6-f706-9f5e-d458-c5264d9b7d64.png)

しかし、DBには削除命令を送っていないので、DB上にはデータが残ったままです。ページを更新すれば、ページコンポーネントがサーバにデータを再要求するので元のレビューデータが表示されます。

## DB削除命令
ゴミ箱ボタンが押されたら、DBに削除命令が走るように、追記していきます。
Prisma操作になるので、サーバー側で操作が必要になります。サーバーアクションを定義し、クライアントコンポーネントへimportすることでサーバー操作を扱うことができます。
サーバーアクションをまとめて定義する場所を作るので、`lib/`の下に`actions.ts`を作成します。

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#with-client-components

```tsx: lib/actions.ts
'use server'

import { prisma } from "./prisma";

export const handleDeleteReview = async (reviewId: number) => {
  try{
      const deleteReview = await prisma.review.delete({
          where:{
              id: reviewId,
          }
      })
  }catch (err) {
      console.error(`レビューの削除に失敗しました。レビューID: ${reviewId}, エラーメッセージ: `, err);
      throw err
  }
}
```



```diff_tsx:components/ReviewCardContainer/ReviewCardContainer.tsx
'use client'

import { ReviewWithProductId } from "@/app/types";
import styles from "./CardContainer.module.css";
import ReviewCard from "@/app/components/ReviewCard/ReviewCard";
import { useState } from "react";
+ import { handleDeleteReview } from "@/lib/actions";

type Props = {
  title: string;
  reviews: ReviewWithProductId[];
}

export default function ReviewCardContainer(props: Props) {

  const [reviews, setReviews] = useState(props.reviews);

  const handleDelete = async (review : ReviewWithProductId) => {
    const answer = confirm(`レビュー記事「${review.title}」を削除しますか？`);
    if(answer){
+      try{
+        //delete DB data
+        handleDeleteReview(review.id)

        //update reviews
        const newReviews = reviews.filter((e) =>  e.id !== review.id )
        setReviews(
          newReviews
        )
+      }catch(err){
+          console.log("レビューの削除に失敗しました。ページを更新後、もう一度お試しください。")
+      }
    }
  }

  
  return ( 

    <div>
      <hr />
      <div className={styles.titleContainer}>
        <h2>{props.title}</h2>
        <p>{reviews.length}</p>
      </div>
      {reviews.length == 0 
      ?  (<p className={styles.noPost}>投稿はありません。</p>) 
      :  reviews.map((review, index) =>(
          <ReviewCard key={index} review={review} onDeleteReview={(review) => handleDelete(review)} />
        ))}
    </div>
  )
}
```

親のサーバーコンポーネント`dashboard/review/page.tsx`で同じ関数を定義してPropsとして渡すこともできますが、３つある`ReviewCardContainer`それぞれに同じPropsを渡さなければならず、記述量が増えて見にくくなるので今回はこの方法にしました。すべて共通で使う機能ですし。
クライアントコンポーネントでも、サーバ処理が記述できるのは感動しました。この機能に気づく前は、せっせと内部API作って`fetch`とか使ってました。記述量も処理速度も、サーバーアクションのほうが優れているように思います。

エラーハンドリングに関しては一旦適当な文言で仮置きです。こちらも奥が深そうなので、もう少し開発を進めてからの課題とします。

# 次回

[Cloudinary](https://cloudinary.com/)を利用して、画像の保存と表示などなど、やっていきます。
Youtubeの出し分けなども必要です。

Supabaseなどのほうが使い慣れてらっしゃる方は、そちらを利用していただいても全然問題ないです。