[Next.js] Supabase カレンダーメモアプリ チュートリアル

Next.jsの初学者向けチュートリアルコンテンツ、カレンダーメモアプリ チュートリアルの後編です。
カレンダー形式のメモアプリをNext.js及びTypeScriptで開発していきます。 また、入力したメモ情報の格納には、BaaS(Backend as a Service)であるSupabaseを利用します。Supabaseとの連携は、Next.jsの Server Actions を利用します。 クライアントコンポーネントからサーバーコンポーネントの実装までフルスタック型の開発を体得します。

後編の今回は、カレンダー、モーダルの作成とロジックの実装、Supabaseの処理を司るServer Actionsの開発、GitHub、Vercel連携について、解説します。

ソースコード:GitHub

前編はこちらです。

5. カレンダーの作成

後編は5章よりスタートです。
カレンダーコンポーネントの、Calendar.tsxを具体的に開発していきます。ここではカレンダーUIを作成の上、Date関数を利用して日付の処理を色々と実装していきます。

5.1 カレンダーUI作成

まず、カレンダーのUI・レイアウトを組み立てます。
Calendar.tsxを以下の内容に変更します。

// /app/components/Calendar.tsx

import { Button } from "@/components/ui/button"; //Buttonコンポーネントインポート
import { ChevronLeft, ChevronRight } from "lucide-react"; //左右アイコンインポート

const Calendar = () => {
  //月の日数
  const daysInMonth = 30;
  //日付を配列を格納
  const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
  console.log("days", days);

  return (
    <div className="w-full max-w-md mx-auto">
      {/* 月の推移用エリア */}
      <div className="flex justify-between items-center mb-4">
        <Button variant="outline">
          <ChevronLeft className="h-4 w-4" />
        </Button>
        <h2 className="text-xl font-bold">2025年4月</h2>
        <Button variant="outline">
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>

      {/* カレンダーの中身*/}
      <div className="grid grid-cols-7 gap-1">
        {/* 曜日の表示 */}
        {["", "", "", "", "", "", ""].map((day) => (
          <div key={day} className="text-center font-bold">
            {day}
          </div>
        ))}

        {/* 日付のマップ */}
        {days.map((day) => (
          <Button key={day} variant="outline" className="h-12 w-full relative">
            {day}
          </Button>
        ))}
      </div>
    </div>
  );
};

export default Calendar;

冒頭は、必要となるコンポーネントをインポートしてます。ボタンのコンポーネントのButton, 左右の→を表すアイコンのインポートです。

次に、daysInMonthとして月の日数を定義してます。今時点は固定値の「30」としてますが、後ほど、該当月に合わせて動的に変更させます。
続いてdaysを定義してますが、これは、daysInMonthの数値を、Array.fromメソッドで、1〜最後の数字まで配列としてセットしています。

現状daysInMonthは30ですので、1〜30までの数字を1つずつ配列として格納してます。console.logを入れてますので、ブラウザの検証ツールでも確認出来ますが、次のイメージです。

[ 1, 2, 3, ... , 30 ]

続いて、JSXの箇所です。コメント記載してますが、月の推移処理を行うエリア、カレンダーの中身、更にその中には、ヘッダの曜日表示の箇所と、日付のマップエリアとで別れています。

<div>で構成された箇所は、Tailwind CSSのクラスをセットしてます。名称から何となく類推出来ると思います。また先述した通り、VSCodeのポップアップでCSS定義内容は確認出来ます。
月の推移の箇所は<Button>で先月は左の矢印、次月は右の矢印を表示させています。その間に、年月を表示させています。今時点は、2025年4月と固定値を記載してます。後ほど、定数に変更します。

カレンダーの中身については、最初の<div>にクラスとしてgrid-cols-7を割り当てており、7列のグリッド設定をしてます。

次の曜日のヘッダの箇所は、[“日”, “月”, “火”, “水”, “木”, “金”, “土”]と曜日の配列をmapメソッドにより、曜日毎にヘッダとして配置してます。
なお、このmapの処理は非常によく利用されます。

そして日付のマップについては、曜日と同様、日付の配列daysの各要素をmapメソッドにより、日付として表示してます。それぞれ<Button>として配置してます。これにより、1~30の日付が、7つずつ配置されます。

この時点で以下の画面となります。

<Button variant=”outline”>の指定で、アウトラインで表示するようにしており、すっきりしたデザインとなっています。
なお、今時点は日付や月推移のボタンをクリックしても何も起きません。今後、定義していきます。

5.2 カレンダー日付実装

UIの作成が完了しましたので、次に、実際に選択された月の日付データを反映するようにします。
これに当たり、JavaScriptのDate APIを活用します。

以下の内容で実装していきます。

(1)
まず、カレンダーに表示させる月を管理するステートcurrentDateを、Reactのフック、useStateで定義します。初期値は、new Date()です。new Date()はDate APIにより現在の日時を取得するものです。

  const [currentDate, setCurrentDate] = useState(new Date());
フック(Hooks)は、コンポーネントから呼び出し利用可能なReactの様々な機能です。
useStateはReactの関数コンポーネントでよく使われる代表的なAPIの1つです。const [state, setState] と、値とそれを変更操作するためのset関数の組み合わせで、慣例的にset関数はset+最初大文字の値名とする事が多い(ここでいうと、state, setState)です。

(2)
次に、現在、固定値30で設定している、その月の日数を定義するdaysInMonthをcurrentDateを基に以下内容に変更します。

const daysInMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth() + 1,
    0
  ).getDate();

これは、例えば、currentDateが2025年4月18日だったとすると、
new Date(currentDate.getFullYear(),currentDate.getMonth() + 1,0)は、
new Date(2025,3 + 1,0)、つまりnew Date(2025,4,0)と言う指定になります。
(monthは0から始まります。0=1月、1=2月・・・です。)
ここで、3つ目の引数、dayの箇所は0を指定しています。これの意味するところは、翌月の0日目 = その月の最終日です。そして、次に続く.getDate()は日付を取得する関数です。
つまり、monthに1を加え、dayに0を指定する事により、「翌月の0日目 ⇒ 今月の最終日 ⇒ .getDate() で日付を取得」しています。4月であれば最終日の30と言う数字がこれで取得出来るわけです。

(3)
続いて、currentDateを基にその月の月初が何曜日か、算出します。

  const firstDayOfMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth(),
    1
  ).getDay();

firstDayOfMonthとして定義していますが、(2)と似た処理です。3つ目の引数、dayの箇所を1で指定していますが、その月の初日を指します。.getDay()は曜日を取得する関数です(0 = 日曜, 1 = 月曜, …, 6 = 土曜)。これにより、月初の曜日が取得出来ます。

(4)
次です。前後月への移動処理を追加します。これはuseStateのsetCurrentDateで、currentDateを更新します。

  const prevMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
    );
  };
  const nextMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
    );
  };

前月の移動は、prevMonth、次月の移動はnextMonthで定義してます。それぞれ、new Dateで、prevMonthであれば月を-1、nextMonthであれば月を+1し、setCurrentDateでセットしてます。

まず、これらをコードに反映します。
以下内容です。

// /app/components/Calendar.tsx
+"use client";//(1)フック利用に当たり、クライアント宣言が必要

import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
+import { useState } from "react";//(1)useStateインポート追加

const Calendar = () => {
+ const [currentDate, setCurrentDate] = useState(new Date());//(1)ステート定義

  //(2)変更:月の日数
- //   const daysInMonth = 30;
+ const daysInMonth = new Date(
+   currentDate.getFullYear(),
+   currentDate.getMonth() + 1,
+   0
+ ).getDate();

+ //(3)月初の曜日を取得
+ const firstDayOfMonth = new Date(
+   currentDate.getFullYear(),
+   currentDate.getMonth(),
+   1
+ ).getDay();

+ //(4)前後の月へ移動
+ const prevMonth = () => {
+   setCurrentDate(
+     new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
+   );
+ };
+ const nextMonth = () => {
+   setCurrentDate(
+     new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
+   );
+ };

  //日付を配列を格納
  const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
  console.log("days", days);
  
  ....

冒頭に”use client”を追加しています。useStateなどReactのフックや、JSXのonClickなどは、クライアント(ブラウザの機能)として処理されます。Next.jsでは、こうしたクライアントコンポーネントとして動作するものは、”use client”と記載し、クライアントコンポーネントである事を明示する必要があります。

Next.jsのコンポーネントはデフォルトではサーバーコンポーネントとして動作します。よってクライアントの場合は、その明示が必要です。なお、どの場合がクライアントとなるのか分からないケースもあるかと思いますが、"use client"が必要で記載が無い場合はエラーが出て怒られますので、エラーに従い対処出来ます(下図参照)。

続いて、JSX箇所に上記追加した処理を反映していきます。
まず、月の推移用エリアの箇所です。

// /app/components/Calendar.tsx
....

 return (
    <div className="w-full max-w-md mx-auto">
      {/* 月の推移用エリア */}
      <div className="flex justify-between items-center mb-4">
-       {/* <Button variant="outline"> */}
+       <Button variant="outline" onClick={prevMonth}>
          <ChevronLeft className="h-4 w-4" />
        </Button>
        <h2 className="text-xl font-bold">
+         {currentDate.toLocaleString("ja-JP", {
+           year: "numeric",
+           month: "long",
+         })}
-         {/* 2025年4月 */}
        </h2>
-       {/* <Button variant="outline"> */}
+       <Button variant="outline" onClick={nextMonth}>
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>

月の前後に推移する、<Buttonの箇所は、onClick処理を追加してます。前月は、onClick={prevMonth}と、prevMonthを実行。次月は、onClick={nextMonth}とnextMonthを実行します。
間の2025年4月の箇所は、下記の記載になっています。

{currentDate.toLocaleString("ja-JP", {
  year: "numeric",//数字として表示する、toLocaleStringで"年"付きで表示される ex.2025年
  month: "long",//日本語の月を表示する、ex.4月
 })}

これは、currentDateをtoLocaleStringメソッドで日本の表記形式に変換しています。year, monthの指定はコメント記載通りです。

続いて、日付のマップ の箇所です。以下、追加します。

// /app/components/Calendar.tsx
....

  {/* 日付のマップ */}
+ {Array(firstDayOfMonth)
+   .fill(null) //firstDayOfMonth分、空の配列を生成
+   .map(
+     (
+       _,
+       index //空の配列を配列数分mapで展開、空の<div>を生成し、カレンダーの日付余白を作成する。
+     ) => (
+       <div key={`empty-${index}`} />
+     )
+   )}
  {days.map((day) => (
    <Button key={day} variant="outline" className="h-12 w-full relative">
      {day}
    </Button>
  ))}

コメント記載してますが、Array(firstDayOfMonth).fill(null)で、空の配列をfirstDayOfMonthの数分作成してます。例えば、firstDayOfMonthが4、つまり木曜日(日曜日が0、月が1….です)の場合は、4つほど、空白の配列を生成します。そしてそれをmapで展開し、空白の

を配列の数分生成してます。これは何をしているかと言うと、カレンダー上で、1日が木曜日であれば、その行の日曜日〜水曜日は空白である必要がある為、その空白を作成していると言うものになります。
そして、その後、元々記載していた{days.map((day) => (で日付をマッピングしてます。

ここまでで、以下の画面となります。その月の曜日・日付が正しくセットされることが分かります。

カレンダーメモアプリ アニメ

6. メモ用モーダルの作成

続いては、カレンダー上にメモを入力する為のモーダルを作成します。カレンダーの日付をクリックするとモーダルが開き、その日のメモを入力・更新・削除する仕組みです。

6.1 モーダルUI作成

まず、UIを作成していきます。モーダルは、MemoModal.tsxで実装します。shadcn/uiのDialogを利用します。

モーダルは通常はクローズしており、カレンダー上の日付クリックをトリガーとしてオープンします。この開閉処理は開閉用のステートを追加して対応します。これは、Calendar.tsx、及びMemoModal.tsx双方で必要となりますので、その親コンポーネントのapp/page.tsxで実装します。
次の内容となります。

(1)
Reactのフック、useStateを利用するため、”use client”の追加、及び、ステート、selectedDate(選択した日付を管理), isModalOpen(モーダルの開閉状態を管理)を追加

(2)
日付をクリックした際の処理追加
selectedDateにクリックした日付を格納、isModalOpenをtrueに

(3)
Calendar, MemoModalにpropsを渡す
Reactにおいてコンポーネント間のデータの受け渡しは、propsを使用します。
propsとはコンポーネント間でデータを受け渡す際のまとまりのようなもので、コンポーネントが受け取ったpropsに基づいてデータを表示できます。

以下、app/page.tsxの変更内容です。

// /app/page.tsx
+"use client"; //(1)追加

+import { useState } from "react"; //(1)useStateインポート追加
import Calendar from "./components/Calendar";
import MemoModal from "./components/MemoModal";

export default function Home() {
+ const [selectedDate, setSelectedDate] = useState<Date | null>(null); //(1)追加
+ const [isModalOpen, setIsModalOpen] = useState(false); //(1)追加

+ //(2)追加:日付をクリックした際の処理
+ const handleDateClick = (date: Date) => {
+   setSelectedDate(date);
+   setIsModalOpen(true);
+ };

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">カレンダーメモアプリ</h1>

      {/* (3)各コンポーネントにpropsを渡す */}
+     <Calendar onDateClick={handleDateClick} />
      <MemoModal
+       isOpen={isModalOpen}
+       onClose={() => setIsModalOpen(false)}
+       date={selectedDate}
      />
    </div>
  );
}

ステート、selectedDateは日付なのでDate型としてます。また、初期値はnullとしている為、useState<Date | null>(null)と言う書き方で、Date型もしくはnull型であることを明示しています。isModalOpenは真偽値(true, false)となります。こちらは、useState(false)で初期値をfalseとしており、型が真偽値(boolean)であることは明白で、TypeScriptにて型推測が可能な為、型明示はしていません。明示をしたい場合は、 useState<boolean>(false)と言う書き方になります。

(3)の各コンポーネントにpropsを渡す処理は、MemoModalの箇所は、onClose={() => setIsModalOpen(false)}としてます。これは、onCloseとして、setIsModalOpen(false)を実行した結果を渡しています。これにより、MemoModalでonCloseを実行するとisModalOpenはfalseとなります。

続いて、propsをCalendar, MemoModalに渡してますので、それぞれのコンポーネントで、propsの受取の定義と、proprsを利用した処理を追加します。
まず、Calendar.tsxです。
以下のように変更します。

(1)
propsの型定義と、コンポーネントのprops定義の追加。
TypeScriptでは、propsについて、型の定義が必要となりますので、これを追加します。
また、コンポーネント定義の箇所に受け取るpropsを型情報と共に、定義する必要がありますので、これを追加します。

(2)
受け取ったpropsを利用した処理を追加します。具体的には、カレンダー上の日付をクリックした際に、selectedDateにその日付をセットする処理です。またこれに当たり、日付情報を取得し格納する定数const dateを定義します。

以下、Calendar.tsx変更内容です(コード全文です)。

// /app/components/Calendar.tsx
"use client";

import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

+//(1)追加:propsの型定義
+type CalendarProps = {
+ onDateClick: (date: Date) => void;
+};

+//(1)追加:propsの定義
+const Calendar = ({ onDateClick }: CalendarProps) => {
  const [currentDate, setCurrentDate] = useState(new Date());

  //月の日数
  const daysInMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth() + 1,
    0
  ).getDate();

  //月初の曜日を取得
  const firstDayOfMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth(),
    1
  ).getDay();

  //前後の月へ移動
  const prevMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
    );
  };
  const nextMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
    );
  };

  //日付を配列を格納
  const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
  console.log("days", days);

  return (
    <div className="w-full max-w-md mx-auto">
      {/* 月の推移用エリア */}
      <div className="flex justify-between items-center mb-4">
        <Button variant="outline" onClick={prevMonth}>
          <ChevronLeft className="h-4 w-4" />
        </Button>
        <h2 className="text-xl font-bold">
          {currentDate.toLocaleString("ja-JP", {
            year: "numeric",
            month: "long",
          })}
        </h2>
        <Button variant="outline" onClick={nextMonth}>
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>

      {/* カレンダーの中身*/}
      <div className="grid grid-cols-7 gap-1">
        {/* 曜日の表示 */}
        {["", "", "", "", "", "", ""].map((day) => (
          <div key={day} className="text-center font-bold">
            {day}
          </div>
        ))}

        {/* 日付のマップ */}
        {Array(firstDayOfMonth)
          .fill(null)
          .map((_, index) => (
            <div key={`empty-${index}`} />
          ))}
        {days.map((day) => {
+         //(2)追加:年月日情報の入ったdate定義
+         const date = new Date(
+           currentDate.getFullYear(),
+           currentDate.getMonth(),
+           day
+         );
+         return (
            <Button
              key={day}
              variant="outline"
              className="h-12 w-full relative"
+             onClick={() => onDateClick(date)} //(2)追加:onClick処理
            >
              {day}
            </Button>
+         );
        })}
      </div>
    </div>
  );
};

export default Calendar;

型定義、type CalendarPropsのonDateClick: (date: Date) => voidは、VSCodeであれば、propsの渡し元、app/page.tsxのprops定義の箇所で、マウスオーバーすると型情報が表示されます(下図参照)。

その型情報をそのまま貼り付ければOKです。

(2)の処理は、日付のボタンをクリックすると、onClick={() => onDateClick(date)}で、dateに格納された日付が、日付用ステートselectedDateに格納されます。

続いて、MemoModal.tsxです。以下の変更を行います。

(1)
Calendar.tsxと同様に、propsの型定義と、コンポーネントのprops定義の追加。

(2)
shadcn/uiのDialog等でモーダルのUIを作成

(3)
受け取ったpropsを利用した処理を追加。具体的には、モーダルの開閉処理、日付の設定です。

以下、MemoModal.tsx変更内容です(コード全文です)。なお、MemoModalはrafceによる雛形の状態ですので、丸ごと置き換えるイメージで更新します。

// /app/components/MemoModal.tsx

"use client";

import {
  //(2)Dialog関係インポート
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; //(2)Buttonインポート
import { Textarea } from "@/components/ui/textarea"; //(2)Textareaインポート

//(1)propsの型定義
type MemoModalProps = {
  isOpen: boolean;
  onClose: () => void;
  date: Date | null;
};

//(1)props定義追加
const MemoModal = ({ isOpen, onClose, date }: MemoModalProps) => {
  if (!date) return null; //nullの可能性ありの為、それを排除

//(3)UIの定義
  return (
    <Dialog
      open={isOpen} //isOpenがtrueであればモーダルオープン
      //openの状態監視、openがfalseであればモーダルクローズ
      onOpenChange={(open) => {
        if (!open) onClose();
      }}
    >
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {date.toLocaleDateString("ja-JP", {
              //dateを日本語表記の年月日に変換
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
            のメモ
          </DialogTitle>
        </DialogHeader>

        <Textarea
          placeholder="メモを入力してください"
          className="min-h-[100px] focus-visible:ring-0"
        />
        <DialogFooter>
          <Button variant="outline" onClick={onClose}>
            キャンセル
          </Button>
          <Button
            onClick={() => {}} //仮設置
          >
            保存
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

export default MemoModal;

ブラウザ機能のonClick を利用してますので、冒頭、”use client”宣言をしています。shadcn/uiの必要なコンポーネントのインポートを行い、propsの型定義の箇所は、isOpen, onClose, dateの型定義をしています。Calendar.tsxの時と同様、app/page.tsxのprops定義の箇所でますオーバーされると表示される型情報をそのまま持ってくれば大丈夫です。onCloseについては、戻り値の無い関数となりますので、型は() => voidと言う形になります。

JSXの箇所は、Dialogの箇所でモーダル(ダイアログ)の開閉条件の指定をしています。isOpenがtrueであればオープンします。DialogContentで囲まれた箇所はモーダルの中身です。メモの日付表示の箇所は、dateを日本語表記(ex. 2025年4月30日)に変換して表示してます。
Textareaでメモの入力エリアが設けられています。
その下のボタンは、「キャンセル」でもーダルクローズ、「保存」の箇所は、onClick={() => {}}の形で今時点は空の処理を記載してます。クリックしても何も起きません。ここは後ほど処理を実装します。

この時点の画面の動きは下図の通りです。カレンダーの日付をクリックすると、モーダルがオープンします。モダール外やキャンセルをクリックするとモーダルが閉じます。

カレンダーメモアプリ アニメ

6.2 メモデータ入力・更新

UIが完成しましたので、続いては実際にメモの入力、更新、削除が出来るようにしていきます。
これに当たり、まず、app/page.tsxに新たにメモを管理するステート、memosを追加します。
そして、メモをステートに格納する処理、handleSaveMemoを追加します。
また、それらをpropsとして、新たにCalendar, MemoModalに渡します。
以下、app/page.tsxの変更内容です。

// //app/page.tsx
"use client";

// import { useState, useEffect } from "react";
import { useState } from "react";
import Calendar from "./components/Calendar";
import MemoModal from "./components/MemoModal";

export default function Home() {
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [isModalOpen, setIsModalOpen] = useState(false);
+ const [memos, setMemos] = useState<{ [key: string]: string }>({}); //追加

  //日付をクリックした際の処理
  const handleDateClick = (date: Date) => {
    setSelectedDate(date);
    setIsModalOpen(true);
  };

+ //メモを保存
+ const handleSaveMemo = (memo: string) => {
+   if (selectedDate) {
+     const dateKey = selectedDate.toISOString().split("T")[0];

+     // UIを更新
+     const newMemos = { ...memos };
+     if (memo.trim() === "") {
+       delete newMemos[dateKey];
+     } else {
+       newMemos[dateKey] = memo;
+     }
+     setMemos(newMemos);
+   }
+ };

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">カレンダーメモアプリ</h1>

      <Calendar
        onDateClick={handleDateClick}
+       memos={memos} //memos追加
      />
      <MemoModal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        date={selectedDate}
        //initialMemo, onSave追加
+       initialMemo={
+         selectedDate ? memos[selectedDate.toISOString().split("T")[0]] : ""
+       }
+       onSave={handleSaveMemo}
      />
    </div>
  );
}

追加したステート、memosは、{ [key: string]: string }と言う形式となってます。これは例えば以下のようなデータとなります。(strong型の)日付をキーとして、中身は、メモの内容となります。

{
    "2025-04-09": "メモです。",
    "2025-04-10": "もう一つメモです",
    ....
}

次のhandleSaveMemoについて解説します。上記で示した、ステートmemosに、入力・更新された内容を”日付”:”メモの内容”で追加・更新します。メモ内容が削除された場合は、memosから削除します。

// /app/page.tsx
....

  //メモを保存
  const handleSaveMemo = (memo: string) => {//引数はmemo: string型
    if (selectedDate) {//selectedDateが存在していれば
	    //selectedDateのTより前のデータを、ISO形式の文字列値としてdateKeyに格納
	    //例:"2025-04-13T15:00:00.000Z".split("T")
	    //[0]はsplitで分割した前のデータ(年月日)を格納する意味
      const dateKey = selectedDate.toISOString().split("T")[0];

      // UIを更新
      const newMemos = { ...memos };//memosの内容をnewMemosに展開
      if (memo.trim() === "") {//空白スペースをtrimで除去した後、内容が空であれば、
        delete newMemos[dateKey];//削除されたものとして、該当日付のメモを削除
      } else {//そうでなければ
        newMemos[dateKey] = memo;//newMemosにdateKeyをキーとして、memoを追加
      }
      setMemos(newMemos);//newMemosで、ステートmemosをセット
    }
  };

解説としてはコメント記載通りです。
const newMemos = { …memos }と記載してますが、この…は、スプレッド構文と呼ばれるものです。配列やオブジェクトの中身を展開し、値の追加や変換等を行う場合に使われます。

また、JSXの箇所で、以下の内容があります。propsとして追加しているinitialMemoのか箇所です。

// /app/page.tsx
....

  //initialMemo, onSave追加
  initialMemo={
  //selectedDateがあれば、前者の処理、無ければ後者の処理
    selectedDate ? memos[selectedDate.toISOString().split("T")[0]] : ""
  }

この処理は三項演算子で処理しています。
これは、条件 ? true時の処理 : false時の処理と言う記載の仕方となります。

selectedDateがあれば、先のhandleSaveMemoと同様、日付の文字列を変換し、memos[”日付”]とする内容です(例えば、memos[”2025-04-21”])。

続いて、propsの追加された、Calendar, MemoModalを変更します。
まず、Calendar.tsxです。

// /app/components/Calendar.tsx
"use client";

import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

type CalendarProps = {
  onDateClick: (date: Date) => void;
+ memos: { [key: string]: string }; //追加
};

//追加:props,memos
+const Calendar = ({ onDateClick, memos }: CalendarProps) => {
  const [currentDate, setCurrentDate] = useState(new Date());

  //月の日数
  const daysInMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth() + 1,
    0
  ).getDate();

  //月初の曜日を取得
  const firstDayOfMonth = new Date(
    currentDate.getFullYear(),
    currentDate.getMonth(),
    1
  ).getDay();

  //前後の月へ移動
  const prevMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
    );
  };
  const nextMonth = () => {
    setCurrentDate(
      new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
    );
  };

  //日付を配列を格納
  const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
  console.log("days", days);

  return (
    <div className="w-full max-w-md mx-auto">
      {/* 月の推移用エリア */}
      <div className="flex justify-between items-center mb-4">
        <Button variant="outline" onClick={prevMonth}>
          <ChevronLeft className="h-4 w-4" />
        </Button>
        <h2 className="text-xl font-bold">
          {currentDate.toLocaleString("ja-JP", {
            year: "numeric",
            month: "long",
          })}
        </h2>
        <Button variant="outline" onClick={nextMonth}>
          <ChevronRight className="h-4 w-4" />
        </Button>
      </div>

      {/* カレンダーの中身*/}
      <div className="grid grid-cols-7 gap-1">
        {/* 曜日の表示 */}
        {["", "", "", "", "", "", ""].map((day) => (
          <div key={day} className="text-center font-bold">
            {day}
          </div>
        ))}

        {/* 日付のマップ */}
        {Array(firstDayOfMonth)
          .fill(null)
          .map((_, index) => (
            <div key={`empty-${index}`} />
          ))}
        {days.map((day) => {
          const date = new Date(
            currentDate.getFullYear(),
            currentDate.getMonth(),
            day
          );
+         const dateKey = date.toISOString().split("T")[0]; //追加
+         const hasMemo = dateKey in memos; //追加
          return (
            <Button
              key={day}
              variant="outline"
              className="h-12 w-full relative"
              onClick={() => onDateClick(date)}
            >
              {day}
+             {hasMemo && ( //追加
+               <div className="absolute bottom-1 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-lime-600 rounded-full opacity-30" />
+             )}
            </Button>
          );
        })}
      </div>
    </div>
  );
};

export default Calendar;

propsの型定義、CalendarPropsに追加されたprops、memosの型定義を追加、その下、コンポーネント定義の箇所で、同様にmemosをpropsとして追加しています。
JSXの箇所は、新たに以下を追加しています。

// /app/components/Calendar.tsx
....

+         const dateKey = date.toISOString().split("T")[0]; //追加
	//datekeyに、日付の文字列を変換し格納(例:"2024-04-25"など)
+         const hasMemo = dateKey in memos; //追加
	//memosに日付が含まれていれば、hasMemoをtrueとして、「メモあり」と判定

意味としてはコメント記載通りです。そしてこのhasMemoでメモありと判定される場合は、 以下の内容で、日付ボタンに薄緑(lime)の円を表示させています。

// /app/components/Calendar.tsx
....

+             {hasMemo && ( //追加
+               <div className="absolute bottom-1 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-lime-600 rounded-full opacity-30" />
+             )}

これでメモがある日は以下のように表示されます。薄緑の円がプロットされています。

カレンダーメモアプリ アニメ

次に、MemoModal.tsxの変更です。
以下、変更内容です。

// /app/components/MemoModal.tsx
"use client";

+import { useState, useEffect } from "react"; //useEffect追加
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";

type MemoModalProps = {
  isOpen: boolean;
  onClose: () => void;
  date: Date | null;
+ initialMemo: string; //追加
+ onSave: (memo: string) => void; //追加
};

const MemoModal = ({
  isOpen,
  onClose,
  date,
+ initialMemo, //追加
+ onSave, //追加
}: MemoModalProps) => {
  const [memo, setMemo] = useState(initialMemo);

+ //追加:useEffectでinitialMemoをmemoにセット
+ useEffect(() => {
+   setMemo(initialMemo);
+ }, [initialMemo, isOpen]);

+ //追加:メモの保存
+ const handleSave = () => {
+   onSave(memo);
+   onClose();
+ };

  if (!date) return null; //nullの可能性ありの為、それを排除

  return (
    <Dialog
      open={isOpen}
      onOpenChange={(open) => {
        if (!open) onClose();
      }}
    >
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {date.toLocaleDateString("ja-JP", {
              //dateを日本語表記の年月日に変換
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
            のメモ
          </DialogTitle>
        </DialogHeader>

        <Textarea
+         value={memo} //追加
+         onChange={(e) => setMemo(e.target.value)} //追加
          placeholder="メモを入力してください"
          className="min-h-[100px] focus-visible:ring-0"
        />
        <DialogFooter>
          <Button variant="outline" onClick={onClose}>
            キャンセル
          </Button>
          <Button
+           onClick={handleSave} //変更
          >
            保存
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

export default MemoModal;

冒頭、インポートの箇所は、Reactのフック(Hooks)、useEffectを追加しています。
続いて、追加されたprops、initialMemo, onSaveの型定義と、コンポーネントのprops定義を追加しています。

JSX文の前に、処理を2つ追加しています。

// /app/components/MemoModal.tsx

  //追加:useEffectでinitialMemoをmemoにセット
  useEffect(() => {
    setMemo(initialMemo);//initialMemoをmemoにセット
  }, [initialMemo, isOpen]);//initialMemo, isOpen変更時に処理

  //追加:メモの保存
  const handleSave = () => {
    onSave(memo);//onSave実行で、memoの内容をmemosに反映
    onClose();//モーダルクローズ
  };

最初のuseEffectは、コンポーネントマウント時、stateの更新時など、特定の条件下で処理(副作用 =effect)を行うものです(厳密に言うと、特定の条件下以外は処理しないの方が正しいです。useEffectはデフォルトは全ての更新に対し処理を行う動きとなる為です。第二引数で特定の条件を指定し、それ以外は処理を除外するように定義します)。ここで言うと、第二引数initialMemo, isOpen変更時に処理されます。処理内容は、setMemo(initialMemo)です。

次のメモの保存処理、handleSaveはコメント記載通りです。

最後のJSXの箇所です。

// /app/components/MemoModal.tsx
....

        <Textarea
+         value={memo} //追加
+         onChange={(e) => setMemo(e.target.value)} //追加
          placeholder="メモを入力してください"
          className="min-h-[100px] focus-visible:ring-0"
        />
        <DialogFooter>
          <Button variant="outline" onClick={onClose}>
            キャンセル
          </Button>
          <Button
+           onClick={handleSave} //変更
          >

Textareaにvalue={memo}を追加しています。これは、このテキストエリアにmemoを値として割り当てています。そして次のonChange={(e) => setMemo(e.target.value)}でonChangeイベントが発生した際、つまり、テキストエリアの入力内容を変更した際に、その入力内容をsetMemoでmemoに格納しています。(e)はイベントの事です。e.target.valueは、そのイベントのvalueを指しています。つまり、valueの変化をsetMemoでセットしていると言う内容です。
その下の、onClick={handleSave}はボタンクリックにより、その前で定義したhandleSaveを実行すると言う内容です。

ここまでで、メモの入力、追加、削除が行えるようになります。下図の動きです。

カレンダーメモアプリ アニメ

ただし、この時点は、Reactの管理するステート上にのみメモ情報が格納されている状態です。これはメモリ上の一時的なものです。よってアプリをリロードすると、入力されたメモは消えてしまいます。
次は、このメモ情報をServer Actionsを使用して、SupabaseのDBに格納するようにしていきます。

7. Server Actions

続いて、SupabaseDBのCRUD(データ作成、取得、更新、削除)操作をServer Actionsのコンポーネントで実装していきます。

Server Actionsは、Next.jsのバージョン13.4で新たに追加された機能で、クライアント側でデータ管理処理を持たず、サーバー上でデータ更新を行うことができる機能です。

クライアントから直接サーバー上の関数を呼び出せる仕組みで、

  • use server ディレクティブをファイルまたは関数の先頭に書くことで、その関数は「サーバー上でのみ実行される」と明示されます。
  • クライアントコンポーネントから直接呼び出すことで、APIルートを経由せずにDBやファイル操作などを行うことが可能になります。
特徴内容
APIルート不要app/api/… を作らずに直接関数を呼び出せる
非同期対応async/await が使える
安全サーバーサイドでしか動かないので、DB操作などに最適
revalidatePath()キャッシュの再検証(ISR再取得)も可能

従来のNext.jsでは:

  • フロント → APIエンドポイントをfetch
  • APIエンドポイント → DBアクセスなど実行

という2段構えの構成が主流でした。

でも実際には:

  • ちょっとしたデータ更新のためにわざわざAPIルートを作るのが面倒…
  • 型の整合性も取りづらい…
  • キャッシュ処理も煩雑…

そこで登場したのが「Server Actions」です。

7.1 Server Actions作成

では、Server Actionsを作成していきます。

appフォルダ直下に、新たにactions.tsと言うファイルを作成します。

作成した、actions.tsに下記コードを記載します。

// /app/actions.ts
"use server";

import { supabase } from "@/lib/supabase";
import { revalidatePath } from "next/cache";

// エラーレスポンスの型定義
export type ActionResponse = {
  success: boolean;
  error?: string;
};

// 全てのメモを取得する処理
export async function getMemos(): Promise<
  { [key: string]: string } | ActionResponse
> {
  const { data, error } = await supabase
    .from("calendar_memos")
    .select("date, memo");

  if (error) {
    console.error("Error fetching memos:", error);
    return {
      success: false,
      error: `メモの取得に失敗しました: ${error.message}`,
    };
  }

  // データを { date: memo } の形式に変換
  const memos: { [key: string]: string } = {};
  data.forEach((item) => {
    memos[item.date] = item.memo;
  });

  return memos;
}

// メモを保存または更新する処理
export async function saveMemo(
  date: string,
  memo: string
): Promise<ActionResponse> {
  try {
    if (memo.trim() === "") {
      // メモが空の場合は削除
      return await deleteMemo(date);
    }

    // 既存のメモを確認
    const { data, error: fetchError } = await supabase
      .from("calendar_memos")
      .select("id")
      .eq("date", date)
      .maybeSingle();

    if (fetchError) {
      return {
        success: false,
        error: `メモの確認に失敗しました: ${fetchError.message}`,
      };
    }

    if (data) {
      // 既存のメモを更新
      const { error } = await supabase
        .from("calendar_memos")
        .update({ memo, updated_at: new Date().toISOString() })
        .eq("date", date);

      if (error) {
        return {
          success: false,
          error: `メモの更新に失敗しました: ${error.message}`,
        };
      }
    } else {
      // 新しいメモを作成
      const { error } = await supabase.from("calendar_memos").insert([
        {
          date,
          memo,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
        },
      ]);

      if (error) {
        return {
          success: false,
          error: `メモの作成に失敗しました: ${error.message}`,
        };
      }
    }

    revalidatePath("/");
    return { success: true };
  } catch (error) {
    console.error("Error saving memo:", error);
    return {
      success: false,
      error: `予期せぬエラーが発生しました: ${
        error instanceof Error ? error.message : String(error)
      }`,
    };
  }
}

// メモを削除する処理
export async function deleteMemo(date: string): Promise<ActionResponse> {
  const { error } = await supabase
    .from("calendar_memos")
    .delete()
    .eq("date", date);

  if (error) {
    return {
      success: false,
      error: `メモの削除に失敗しました: ${error.message}`,
    };
  }

  revalidatePath("/");
  return { success: true };
}

解説していきます。まず、冒頭のインポート 〜 型定義の箇所です。

// /app/actions.ts
"use server";//Server Actions利用する場合は、"use server"を指定

import { supabase } from "@/lib/supabase";//supabaseクライアントのインポート
import { revalidatePath } from "next/cache";//変更があったらページキャッシュを再取得して表示を更新する機能

// エラーレスポンスの型定義
export type ActionResponse = {
  success: boolean;
  error?: string;
};

冒頭の”use server”はコメント記載通り、Server Actions利用する場合に必須の記述となります。クライアントコンポーネントにおける、”use client”と同じようなものです。
次のインポートの箇所は、3章で作成した、supabaseクライアントのインポート及び、更新を反映させ表示するnext/cacheの機能revalidatePathをインポートしてます。

型定義の箇所は、エラーレスポンスの型定義をしています。内容は、成功/失敗を表すレスポンスの共通型として定義されています。

次に、SupabaseのDBからメモ情報を取得する処理getMemos()です。これは、カレンダー初期表示時に全メモを取得するために使われます。

// /app/actions.ts
....

// 全てのメモを取得する処理
export async function getMemos(): Promise<
  { [key: string]: string } | ActionResponse
  //{ "2025-04-21": "メモ内容" } 形式のオブジェクトに変換して返す
  //エラー時は ActionResponse を返す。
> {
  const { data, error } = await supabase//supabaseSDKを利用して
    .from("calendar_memos")//calendar_memosテーブルから
    .select("date, memo");//date, memoのデータを全件取得

  if (error) {//エラー発生した場合は、
    console.error("Error fetching memos:", error);//エラーのコンソール出力
    return {//success:falseと、errorとしてエラー内容を返す
      success: false,
      error: `メモの取得に失敗しました: ${error.message}`,
    };
  }

  // データを { date: memo } の形式に変換
  const memos: { [key: string]: string } = {};
  data.forEach((item) => {//取得したデータを1件ずつ、
    memos[item.date] = item.memo;//{ date: memo } の形式に変換して、memosに追加
  });

  return memos;//{ date: memo } の形式に変換されたデータ全件分をmemosとしてリターン
}

処理内容としてはコメント記載通りです。getMemos(): Promise< { [key: string]: string } | ActionResponse>の記述でgetMemos()の戻り値の型を指定しています。なお、Promiseは非同期通信の事です。

const { data, error } = await supabase.from(…).select(…)はSupabaseのデータ取得時の記法です。

続いては、メモを保存または更新する処理、saveMemo()です。

// /app/actions.ts
....

// メモを保存または更新する処理
export async function saveMemo(//引数は、date, memo
  date: string,
  memo: string
): Promise<ActionResponse> {//戻り値は、ActionResponse 
  try {
    if (memo.trim() === "") {
      // メモが空の場合は後続の削除処理を実行
      return await deleteMemo(date);
    }

    // 既存のメモを確認
    const { data, error: fetchError } = await supabase
      .from("calendar_memos")
      .select("id")
      .eq("date", date)
      .maybeSingle();

    if (fetchError) {//確認失敗の場合は、
      return {//success:falseと、errorとしてエラー内容を返す
        success: false,
        error: `メモの確認に失敗しました: ${fetchError.message}`,
      };
    }

    if (data) {//既存メモが存在する場合は、
      // updateメソッドで、dateがマッチする既存のメモを更新
      const { error } = await supabase
        .from("calendar_memos")
        .update({ memo, updated_at: new Date().toISOString() })
        //updated_atは、文字列に変換して保存
        .eq("date", date);

      if (error) {//エラー発生した場合は、
        return {//success:falseと、errorとしてエラー内容を返す
          success: false,
          error: `メモの更新に失敗しました: ${error.message}`,
        };
      }
    } else {//既存でメモが無い場合は
      // insertメソッドで、新しいメモを作成
      const { error } = await supabase.from("calendar_memos").insert([
        {
          date,
          memo,
          created_at: new Date().toISOString(),//文字列に変換して格納
          updated_at: new Date().toISOString(),//文字列に変換して格
        },
      ]);

      if (error) {//エラー発生した場合は、
        return {//success:falseと、errorとしてエラー内容を返す
          success: false,
          error: `メモの作成に失敗しました: ${error.message}`,
        };
      }
    }

    revalidatePath("/");//画面を最新状態に更新
    return { success: true };
  } catch (error) {//エラー発生した場合は、
    console.error("Error saving memo:", error);//エラーのコンソール出力
    return {//success:falseと、errorとしてエラー内容を返す
      success: false,
      error: `予期せぬエラーが発生しました: ${
      //error が Error オブジェクトなら、その message を使う
			// そうでなければ、文字列として変換して使う
        error instanceof Error ? error.message : String(error)
      }`,
    };
  }
}

処理内容としてはコメント記載どおりですが、以下の流れで処理してます。

  • メモが空の場合は、この後記述のある、deleteMemo()でメモを削除
  • 日付が既に存在してるメモであれば、その日付のメモ内容を更新(update)
  • 日付が存在しないメモであれば、新規メモとして、データ作成(insert)

update, insert処理のSupabase公式ドキュメントは以下です。

なお、最後のエラー処理の箇所ですが、JavaScriptのErrorインスタンスによるエラーの場合は、error.messageを表示し、それ以外の場合は、error内容を文字列として表示する処理をしてます。

最後は、メモを削除する処理、deleteMemo()です。

// /app/actions.ts
....

// メモを削除する処理
export async function deleteMemo(date: string): Promise<ActionResponse> {
//引数は、date, 戻り値は、ActionResponse
  const { error } = await supabase
    .from("calendar_memos")
    .delete()//deleteメソッドで、dateに該当するレコードを削除
    .eq("date", date);

  if (error) {//エラー発生した場合は、
    return {//success:falseと、errorとしてエラー内容を返す
      success: false,
      error: `メモの削除に失敗しました: ${error.message}`,
    };
  }

  revalidatePath("/");//画面を最新状態に更新
  return { success: true };
}

引数dateにマッチするレコードをdeleteメソッドで削除しています。

処理が完了したら、画面を最新状態に更新しています。

7.2 Server Actions実装

続いて、作成したServer Actionsをクライアントコンポーネントから呼び出し、実装していきます。

/app/page.tsxに変更を加えます。以下、変更内容です。

// /app/page.tsx
"use client";

+import { useState, useEffect } from "react"; //useEffect追加
import Calendar from "./components/Calendar";
import MemoModal from "./components/MemoModal";
+import { getMemos, saveMemo } from "./actions"; //Server Actionsインポート追加
+import { toast } from "sonner"; //トースト機能追加
+import { Loader2 } from "lucide-react"; //ローディングアニメーション追加

export default function Home() {
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [memos, setMemos] = useState<{ [key: string]: string }>({});
+ const [isLoading, setIsLoading] = useState(true); //追加、ローディング状態用ステート

+ // 追加:初期ロード時にメモを取得
+ useEffect(() => {
+   async function fetchMemos() {
+     try {
+       const response = await getMemos();
+
+       // エラーレスポンスかどうかをチェック
+       if ("success" in response && !response.success) {
+         toast.error("エラー", {
+           description: response.error || "メモの取得に失敗しました",
+         });
+         setMemos({});
+       } else {
+         setMemos(response as { [key: string]: string });
+       }
+     } catch (error) {
+       console.error("Failed to fetch memos:", error);
+       toast.error("エラー", {
+         description: "メモの取得中にエラーが発生しました",
+       });
+       setMemos({});
+     } finally {
+       setIsLoading(false);
+     }
+   }
+
+   fetchMemos();
+ }, []);

  //日付をクリックした際の処理
  const handleDateClick = (date: Date) => {
    setSelectedDate(date);
    setIsModalOpen(true);
  };

  //メモを保存
+ const handleSaveMemo = async (memo: string): Promise<boolean> => {
- //const handleSaveMemo = (memo: string) => {
    if (selectedDate) {
      const dateKey = selectedDate.toISOString().split("T")[0];
      //追加
+     try {
+       // 追加:Server Actionを使用してメモを保存
+       const response = await saveMemo(dateKey, memo);
+        if (!response.success) {
          // 追加:エラーメッセージをトーストで表示
+         toast.error("エラー", {
+           description: response.error || "メモの保存に失敗しました",
+         });
+         return false;
+       }

        // UIを更新
        const newMemos = { ...memos };
        if (memo.trim() === "") {
          delete newMemos[dateKey];
        } else {
          newMemos[dateKey] = memo;
        }
        setMemos(newMemos);
        console.log("memos", memos);

        // 追加:成功メッセージをトーストで表示
+       toast.success("成功", {
+         description: "メモを保存しました",
+       });
+
+       return true;
+     } catch (error) {
+       console.error("Failed to save memo:", error);
+
        // 追加:エラーメッセージをトーストで表示
+       toast.error("エラー", {
+         description: "予期せぬエラーが発生しました",
+       });
+
+       return false;
+     }
+   }
+   return false;
  };

+ //追加:ローディング中表示
+ if (isLoading) {
+   return (
+     <div className="flex justify-center items-center h-screen">
+       <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+       読み込み中...
+     </div>
+   );
+ }

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">カレンダーメモアプリ</h1>

      <Calendar onDateClick={handleDateClick} memos={memos} />
      <MemoModal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        date={selectedDate}
        initialMemo={
          selectedDate ? memos[selectedDate.toISOString().split("T")[0]] : ""
        }
        onSave={handleSaveMemo}
      />
    </div>
  );
}

冒頭のインポート箇所は、ReactのuseEffect, 作成したServer Actions, トースト機能とローディングアニメーションを追加しています。
次に、SupabaseDBの操作を行うため、処理状況(ローディング)を管理するステート、[isLoading, setIsLoading]を追加しています。

続いて、初期ロード時にメモを取得処理です。これは、useEffectで実装しています。

// /app/page.tsx
....

  // 追加:初期ロード時にメモを取得
  useEffect(() => {
    async function fetchMemos() {//async/awaitの非同期処理
      try {//try-catchで、Server ActionsのgetMemosを実行
        const response = await getMemos();

        // エラーレスポンスかどうかをチェック
        if ("success" in response && !response.success) {//エラーレスポンスであれば
          toast.error("エラー", {//トースト機能でエラー表示
            description: response.error || "メモの取得に失敗しました",
          });
          setMemos({});//memosは空でセット
        } else {//エラーでなければ
        //応答データを"日付":"メモの内容"の形式でmemosに格納
          setMemos(response as { [key: string]: string });
        }
      } catch (error) {//エラー発生時は、
        console.error("Failed to fetch memos:", error);//コンソールエラー出力
        toast.error("エラー", {//トースト機能でエラー表示
          description: "メモの取得中にエラーが発生しました",
        });
        setMemos({});//memosは空でセット
      } finally {
        setIsLoading(false);//最後にローディング状態を解除
      }
    }

    fetchMemos();//fetchMemosを実行
  }, []);

処理内容はコメント通りですが、非同期処理である、async/await及びtry-catchで処理をしています。

なお、useEffectは、直接async処理が実行出来ないと言う制約があります。よって、fetchMemosでasync関数を定義し、useEffectでfetchMemosを実行する形となっています。

fetchMemosで、Server ActionsのgetMemosを実行し、SupabaseDBに登録されたメモ全件を初期ロード時に取得しています。エラー発生時は、トースト機能を利用し、メッセージを表示させます。トーストは以下のようなイメージです。

次に、メモを保存する、handleSaveMemoです。

// /app/page.tsx
....

 //メモを保存
+ const handleSaveMemo = async (memo: string): Promise<boolean> => {
	  //async/awaitの非同期処理に変更、戻り値は、Promise<boolean>
-   //const handleSaveMemo = (memo: string) => {
    if (selectedDate) {
      const dateKey = selectedDate.toISOString().split("T")[0];
      //追加, try-catchで処理
+     try {
        // 追加:Server Actionを使用してメモを保存
+       const response = await saveMemo(dateKey, memo);
+       if (!response.success) {
          // 追加:エラーメッセージをトーストで表示
+         toast.error("エラー", {
+           description: response.error || "メモの保存に失敗しました",
+         });
+         return false;
+       }

        // UIを更新
        const newMemos = { ...memos };
        if (memo.trim() === "") {
          delete newMemos[dateKey];
        } else {
          newMemos[dateKey] = memo;
        }
        setMemos(newMemos);
        console.log("memos", memos);

        // 追加:成功メッセージをトーストで表示
+       toast.success("成功", {
+         description: "メモを保存しました",
+       });
+
+       return true;
+     } catch (error) {
+       console.error("Failed to save memo:", error);
+
        // 追加:エラーメッセージをトーストで表示
+       toast.error("エラー", {
+         description: "予期せぬエラーが発生しました",
+       });
+
+       return false;
+     }
+   }
+   return false;
  };

従来のhandleSaveMemoをServer ActionsのsaveMemoでSupabaseに保存する処理に変更しています。これに伴い、async/awaitの非同期処理に変更しています。
処理の成否をトーストでメッセージ表示させています。

続いて、ローディング中の場合は、ローディングアニメーションと「読み込み中…」と表示させる処理を追加しています。

// /app/page.tsx
....

  //追加:ローディング中表示
  if (isLoading) {//ロ-ディング中であれば、以下リターン
    return (
      <div className="flex justify-center items-center h-screen">
        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
        読み込み中...
      </div>
    );
  }

ローディングアニメーションは、Lucide ReactのLoader2で実現してます。
その下のJSXの箇所は変更ありません。

続いて、MemoModal.tsxを更新します。
以下内容です。

// /app/components/MemoModal.tsx
"use client";

import { useState, useEffect } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
+import { Loader2 } from "lucide-react"; //ローディングアニメーション追加

type MemoModalProps = {
  isOpen: boolean;
  onClose: () => void;
  date: Date | null;
  initialMemo: string;
+ onSave: (memo: string) => Promise<boolean>; //変更
};

const MemoModal = ({
  isOpen,
  onClose,
  date,
  initialMemo,
  onSave,
}: MemoModalProps) => {
  const [memo, setMemo] = useState(initialMemo);
+ const [isSaving, setIsSaving] = useState(false); //追加

  //useEffectでinitialMemoをmemoにセット
  useEffect(() => {
    setMemo(initialMemo);
  }, [initialMemo, isOpen]);

+ //変更:メモの保存
- //   const handleSave = () => {
- //     onSave(memo);
- //     onClose();
- //   };
+ const handleSave = async () => {
+   setIsSaving(true);
+   try {
+     const success = await onSave(memo);
+     if (success) {
+       onClose();
+     }
+   } catch (error) {
+     console.error("Failed to save memo:", error);
+   } finally {
+     setIsSaving(false);
+   }
+ };

  if (!date) return null; //nullの可能性ありの為、それを排除

  return (
    <Dialog
      open={isOpen}
      onOpenChange={(open) => {
        if (!open) onClose();
      }}
    >
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {date.toLocaleDateString("ja-JP", {
              //dateを日本語表記の年月日に変換
              year: "numeric",
              month: "long",
              day: "numeric",
            })}
            のメモ
          </DialogTitle>
        </DialogHeader>

        <Textarea
          value={memo}
          onChange={(e) => setMemo(e.target.value)}
          placeholder="メモを入力してください"
          className="min-h-[100px] focus-visible:ring-0"
+         disabled={isSaving} //追加
        />
        <DialogFooter>
          <Button
            variant="outline"
            onClick={onClose}
+           disabled={isSaving} //追加
          >
            キャンセル
          </Button>
          <Button
            onClick={handleSave}
+           disabled={isSaving} //追加
          >
-           保存
+           {isSaving ? ( //追加
+             <>
+               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+               保存中...
+             </>
+           ) : (
+             "保存"
+           )}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

export default MemoModal;

インポート箇所はローディングアニメーションを追加しています。また、propsの型定義で、onSave(/app/page.tsxのhandleSaveMemo)は、async処理でbooleanを返す形に変更しましたので、そのように型定義を変更しています。
そして、こちらも処理状況を管理するステート、[isSaving, setIsSaving]を追加しています。

続いて、handleSaveですが、以下の通り、onSaveに合わせてasync/awatの非同期処理に変更してます。

// /app/components/MemoModal.tsx
....

+ //変更:メモの保存
- //   const handleSave = () => {
- //     onSave(memo);
- //     onClose();
- //   };
+ const handleSave = async () => {
+   setIsSaving(true);
+   try {
+     const success = await onSave(memo);
+     if (success) {
+       onClose();
+     }
+   } catch (error) {
+     console.error("Failed to save memo:", error);
+   } finally {
+     setIsSaving(false);
+   }
+ };

そして処理の最初に、setIsSaving(true)で処理中とし、最後にsetIsSaving(false)で処理状態を解除しています。

JSXでの箇所は、isSavingの状態によって、処理を変えています。isSavingがtrueであれば、Textarea, Buttonを無効化し、「保存」ボタンは、ローディングアニメーションと共に「保存中…」と表示するようにしています。

ここまでで、以下画面のような動きが実装されます。

カレンダーメモアプリ アニメ

Supabase上でもDBデータが反映されていることが確認出来ると思います。

以上でアプリの開発は終了です。
次は、GitHubへのプッシュと、Vercelへのデプロイを実行します。

8. GitHub、Vercel連携

コード作成の区切りが付きましたので、この時点で、リポジトリ作成とGitHubへのコミット(push)を行います。また、アプリのデプロイ先は、Vercelを利用したいと思います。そしてvercelとGitHub連携を行い、GitHubプッシュ時にvercelに自動デプロイされるようにします。
VercelはNext.jsの開発元なので、Next.jsと親和性が高く、スムーズな連携が可能です。

8.1 GitHub連携

まず、GitHubとの連携です。
前提として、GitHubアカウント及び利用する環境は既にあるものとします。
GitHub環境の準備等については、こちらを参考にしてください。

GitHubでのブランチの作成、コミット、プッシュはVSCode上で簡単に実施出来ます。
まずリポジトリの作成を行います。下記画像の通り、左側のメニューペインで赤丸の箇所(ソース管理)をクリックして、リポジトリを初期化をクリックします。

これでローカル上のリポジトリが作成出来ましたので、これまで作成してきたプロジェクトデータをコミットします。以下のGIF動画を参照してください。
コミット時は、コメント(コミットの意図、位置づけを定義する)が必要ですので適当/適切なコメントを入力の上、右上にある✓ボタンをクリックします。色々ポップアップが出ますが、「はい」で大丈夫です。
(なお、左側に表示されているメッセージ欄にコミットメッセージを入力の上、WindowsであればCtrl-Enter、Macであれば⌘Enterでもコミット出来ます。)

ローカルリポジトリのコミットが完了したら、リモートリポジトリ(GitHub)にブランチ発行・プッシュします。ブランチの発行をクリックすると、GitHubのリポジトリ作成の名前候補が表示されますので、名前を設定し、発行を行います。なお、Privateは一般非公開のもの、Publicは公開のものです。どちらかを選択します。

成功すれば下記のような成功メッセージが表示されます。

これでGitHubとの連携、リポジトリのブランチ発行が出来ました。

8.2 事前ビルド

続いて、ローカル環境で一度アプリのビルドを実行します。
なお、Vercelや、AWS Amplify等のWebアプリをホスティングするサービスは色々ありますが、ローカルでアプリのビルドが失敗する場合は、同様にアプリホスティングサービスでも失敗しますので、事前確認の意味でもローカルでのビルドをした方が良いです。
以下コマンドでビルドを実行します。

npm run build

以下のように実行結果が出力されます。

> calendar-memo-app@0.1.0 build
> next build

    Next.js 15.3.0
   - Environments: .env.local

   Creating an optimized production build ...
  Compiled successfully in 5.0s
  Linting and checking validity of types    
  Collecting page data    
  Generating static pages (6/6)
  Collecting build traces    
  Finalizing page optimization    

Route (app)                                 Size  First Load JS    
  /                                    22.7 kB         133 kB
  /_not-found                            977 B         102 kB
  /sample                                139 B         101 kB
 ƒ /sample/[id]                           139 B         101 kB
+ First Load JS shared by all             101 kB
   chunks/4bd1b696-e1b3c7bcbfce1e14.js  53.2 kB
   chunks/684-e7e3200b42f0ec79.js       45.9 kB
   other shared chunks (total)          1.89 kB


  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

npm run build は、開発環境で作成したコードを本番環境で動作させるために必要な変換処理です。

なお、型チェックのエラー等、ビルド時、エラーが発生する場合は、プロジェクトルート直下にある、
next.config.tsに以下のように定義を追加してみてください。ビルド時の型チェックをスキップするオプションを追加しています。
// /next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  //追加
  typescript: {
    // ビルド時の型チェックをスキップ
    ignoreBuildErrors: true,
  },
  eslint: {
    // ESLintのチェックもスキップ
    ignoreDuringBuilds: true,
  },
};

export default nextConfig;

8.3 Vercelデプロイ

続いて、VrecelでのGitHub連携とデプロイについて解説します(画面は記事作成時点のものです。変更されている可能性もありますが、推測は出来ると思います)。
まず、Vercelのサイトにアクセスします。

上部右の「Login」をクリックします。

ログインオプションが表示されますので、GitHubを選択し、ログインします。

Overview画面に遷移しますので、「Add New …」から「Project」を選択します。

Import Git Repositoryの箇所で「Add GitHub Account」を選択します。

GitHubのセッティング画面に遷移しますので、Repository accessの設定を行います。全てのリポジトリを読み込みするか、特定のリポジトリのみにするか、選択出来ます。どちらでも構わないと思います。
設定したら、「Save」ボタンをクリックします。
(ここは初めての方は「Install」になってるかも知れません。)

Import Git Repository画面に戻りますので、対象となるリポジトリで「Import」ボタンをクリックします。

New Projectの画面となりますので、「Environment Variables」の箇所に、.env.localで定義した環境変数(FirebaseのAPI Key等)を定義します。なお、.env.localの内容を丸ごとコピーしてペーストすると、そのまま定義が設定項目に自動で挿入されます。

下図のイメージです。
Vercelでは、このように環境変数として設定が可能なため、.env, .env.local等の環境変数ファイルをGitHubにアップロードする必要はありません。

「Deploy」ボタンをクリックします。
Deployが実行され、完了すると、下図のようにCongratulations!が表示され、アプリのプレビュー画面が表示されます。

プレビュー画面をクリックすると、デプロイしたアプリサイトに遷移します。無事に動作すれば完成です!なお、以降は更新をGitHubにプッシュする度に、自動でVercelでもデプロイされます。

ここまでで、本チュートリアル後編は終了です。
どなたかの参考になれば嬉しいです。

Next.js Supabase カレンダーメモアプリ チュートリアル

No responses yet

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

AD




TWITTER


アーカイブ
OTHER ISSUE
PVアクセスランキング にほんブログ村