react+Supabase

前回に続き、学習記録アプリのチュートリアルの2回目です。今回は、学習記録データの格納に、BaaS(Backend as a Service)であるSupabaseを利用してみます。
コードは前回の記事をベースに変更していきます。
前回の記事はこちらです。

はじめに

本記事は、Reactの初学者向けのチュートリアルコンテンツです。前回の記事をベースにBaaSである、Supabaseのデータ処理を実装し、実際にアプリケーションをデプロイしてユーザーが使えるリリースまでを行えます。
React環境は、Vite+TypeScript
CSS+UIツールとしては、Chakra UI、アイコンとして、React Icons
BaaSとしてSupabase
ホスティングサービスは、GoogleのFirebaseを利用しています。
本記事で以下の対応が可能です。

  • React+Vite環境の構築
  • useStateによるState管理、Propsの扱い、useEffectの使用
  • Formイベント、イベントハンドラーの扱い
  • TypeScriptのコード記述
  • ChakraUIの導入・利用
  • Reactアイコンの利用
  • Supabaseの環境設定とテーブル処理(表示、登録、更新、削除)
  • GitHubリポジトリの扱い
  • Firebaseホスティングの利用、GitHubと連携した自動デプロイ 等

1. Supabase環境の準備

まずは、Supabaseのアカウントを作成します。Supabaseのサイトに行き、Sign Upをします。
https://supabase.com/
GitHubアカウントとの連携も出来ます。

アカウント作成後、NewProjectで新しいプロジェクトを作成します。プロジェクト名はお好きなものをつけてください。Regionの選択はデフォルトで結構です(違うものでもいいかと思います)。

1.1 テーブルの作成

作成したプロジェクトを選択の上、左側にhover時表示されるメニューから「Table Editor」を選択します。

「Create a new table]を選択します。

Nameは「learning_record」で(※お好きなもので結構です)。
Descriptionは任意で
Enable Row Level Security (RLS)Recommended は、デフォルトのONのままで
Enable Realtime は不要です。
Columnsは以下内容で設定してください。

NameTypeDafault ValuePrimaryExtra Options
iduuidgen_random_uuid()
titlevarchar選択なし
timeinit4選択なし

Foreign keysは不要です。

次に作成したtableにデータを作成しておきます。
左メニューのTable Editorから「learning_record」(作成したテーブル)を選択し、insert → insert lowをクリックします。

右側にデータの内容を入力するエリアが表示されますので、データを入力します。ひとまず、初期データとして下記のReactとTypeScriptの2つセットします(idは自動でセットされますので、入力不要です)。

idtitletime
自動セットReact10
自動セットTypeScript5

「learning_record」の内容が以下のように表示されるかと思います。

1.2 ポリシーの作成

続いて作成したテーブルに対して、ポリシー作成を行います。これはテーブルのデータ操作(SELECT,INSERT,UPDATE,DELETE)に対する権限を設定するものです。
作成テーブル「learning_record」の右側にある、・・・の箇所をクリックすると表示されるメニューから、View Policies を選択します。

表示される画面の右側の、Create Policyをクリックします。

以下画面のように設定していきます。

  • Policy Name:任意のポリシー名を入力します。ここではlearning-records policyとしてます。
  • Polciy Command:ALLを選択、全ての操作に共通のポリシーとしてます。
  • Target Roles:anonを選択
  • Use options above to edit:7行目に、tureと記載
  • Use check expression:チェックしない

この内容で、画面下にある、Save policyをクリックしてポリシーを保存します。

2. SupabaseDBとの連携機能

続いて、前回開発したプロジェクト、コードベースに、SupabaseのDBとの連携機能を実装していきます。(前回の記事の対応されていない方も、コードはこれから全部記載していきますので、大丈夫です。が、前回記事の、1. 環境構築の箇所は実施お願いします。特にmain.tsxへのChakra UIのインポートと<ChakraProvider>タグで囲む記載を忘れずに)。

まずは、supabaseのパッケージをインストールします。
ターミナルにて、プロジェクトのディレクトに移動し、下記コマンドを投入します。

 npm install @supabase/supabase-js

続いてVSCode等のエディタでプロジェクトを開きます。

2.1 Supabase環境変数の設定

Supabaseのテーブルにアクセスする為の環境変数を定義したファイルを作成します。
プロジェクトルート配下に、.envと言うファイルを作成してください。

.envに下記の内容を記載します。

// /.env

VITE_SUPABASE_URL=Supabaseのアクセス先URL
VITE_SUPABASE_ANON_KEY=SupabaseAPIキー

ここで、VITE_SUPABASE_URLとVITE_SUPABASE_ANON_KEYを記載しますが、それぞれの内容は、Supabaseのサイトにて確認します。Supabaseのプロジェクトの設定 > APIから、Project URL及びProject API Keys内のanon keyをコピー(下図参照)し、上記の.envにそれぞれ貼り付けます。

2.2 Supabase呼出機能の作成

次に、SupabaseのDBデータ呼出の機能を切り出して作成します。他のコンポーネントからDBデータの参照や更新等の処理を行う際は、この機能を呼び出して利用する形です。

/src配下にsupabaseClient.tsと言うファイルを作成します。

以下コードを記載します。

// /src/supabaseClient.ts
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseKey);

.envの環境変数もこちらのファイルに記載する事も考えそうですが、.envと本ファイルを分離するのはセキュリティ的理由です。この辺は以下記事をご参考ください。

3. Supabaseデータの表示

続いて、Supabaseのデータを読み込み、学習記録画面(App.tsx)に表示させる処理を実装します。

3.1 型定義ファイルの作成

まず、DBデータの読み込みデータの型定義を独立したコンポーネントで作成します。これは別ファイルを作らなければならないと言うわけではありませんが、型定義が複雑になってくる、あるいは、各コンポーネントで共通的に使うと言うケースが多い場合は、型定義専用のコンポーネントを作成するのが合理的です。

/src配下に、studyData.tsと言うファイルを作成します。
studyData.tsに下記コードを記載します。Supabaseのテーブル「learning_record」で定義されている項目です。

// /src/studyData.ts
export type StudyData = {
    id: string,
    title: string,
    time: number
}
前回のローカルストレージ方式では、idは特に定義はありませんでしたが、DBの操作の場合、idをキーに操作する場合もありますので、idの定義もしています。

続いて、この型定義を、各コンポーネントに反映、インポートします。
まず、Asp.tsxです。型定義、StudyDataをインポートしています。また、stateのlearnings,setLearningsの型をStudyDataとして定義してます。

// /src/App.tsx
import { useEffect, useState } from "react";
import { Box, Card, CardBody, CardHeader, Flex, Heading, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import NewEntry from "./components/NewEntry";
import Edit from "./components/Edit";
import Delete from "./components/Delete";
import { StudyData } from "./studyData";//型定義、StudyDataのインポート

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);//型定義の変更


続いて、NewEntry.tsxです。型定義、StudyDataをインポートしています。
learnings, setLearningsの型定義をStudyDataに、
learning, setLearningの型をStudyDataとして定義し、また、idを新たに加えています。

// /src/components/NewEntry.tsx
import { useRef, useState } from "react";
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, useDisclosure } from "@chakra-ui/react";
import { StudyData } from "../studyData";//型定義、StudyDataのインポート

type Props = {
    learnings: StudyData[]//変更
    setLearnings: (learnings: StudyData[]) => void//変更
    savedLearning: (learnings: { title: string, time: number }[]) => void
}

const NewEntry: React.FC<Props> = ({ learnings, setLearnings, savedLearning }) => {
    const [learning, setLearning] = useState<StudyData>({ id: "", title: "", time: 0 })//変更


Edit.tsxです。同様に型定義、StudyDataをインポートしています。
StudyDataの型に該当するものをStudyDataとして定義しています。

// /src/components/Edit.tsx
import React, { useRef, useState } from 'react';
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { FiEdit } from "react-icons/fi"
import { StudyData } from '../studyData';//型定義、StudyDataのインポート

type Props = {
    index: number
    learning: StudyData //変更
    learnings: StudyData[]//変更
    setLearnings: (learnings: StudyData[]) => void//変更
    savedLearning: (learnings: { title: string, time: number }[]) => void
}


Delete.tsxです。同様に型定義、StudyDataをインポートしています。
StudyDataの型に該当するものをStudyDataとして定義しています。

// /src/components/Delete.tsx
import React, { useRef } from 'react';
import { Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { MdDelete } from 'react-icons/md';
import { StudyData } from '../studyData';//型定義、StudyDataのインポート

type Props = {
    index: number
    learning: StudyData //変更
    learnings: StudyData[]//変更
    setLearnings: (learnings: StudyData[]) => void//変更
    savedLearning: (learnings: { title: string, time: number }[]) => void

3.2 テーブルデータ読込処理

続いて、Supabaseのテーブルからデータをフェッチし、表示させる機能を実装します。
これに伴い、これまでローカルストレージに格納・読出していた処理を廃止します。

まず、App.tsxからローカルストレージ格納機能の、savedLearning及び、呼出処理のuseEffect箇所を削除します。伴い、savedLearningを利用していた各コンポーネントから、当該機能を削除します。

// /src/App.tsx

//下記、ローカルストレージ格納機能、savedLearningを削除します。
  const savedLearning = (learnings: { title: string, time: number }[]) => {
    localStorage.setItem("learnings", JSON.stringify(learnings));
  };

//下記、ローカルストレージ読出処理のuseEffect箇所を削除します。
    useEffect(() => {
    const storedLearning = localStorage.getItem("learnings");
    if (storedLearning) {
      setLearnings(JSON.parse(storedLearning));
    }
  }, []);

//各コンポーネントに渡している、savedLearningを削除します。

<Edit learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} {/* savedLearning={savedLearning}削除 */}/> 
.
.
<Delete learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} {/* savedLearning={savedLearning}削除 */}/>
.
.
<NewEntry learnings={learnings} setLearnings={setLearnings} {/* savedLearning={savedLearning}削除 */}/>


NewEntry.tsxです(コード全文を記載してます)。savedLearning関連の記載を削除します。

// /src/components/NewEntry.tsx
import { useRef, useState } from "react";
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, useDisclosure } from "@chakra-ui/react";
import { StudyData } from "../studyData";

type Props = {
    learnings: StudyData[]
    setLearnings: (learnings: StudyData[]) => void
    //savedLearning: (learnings: { title: string, time: number }[]) => void 削除
}

const NewEntry: React.FC<Props> = ({ learnings, setLearnings/*savedLearning削除*/ }) => {
    const [learning, setLearning] = useState<StudyData>({ id: "", title: "", time: 0 })
    const [error, setError] = useState("")
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

    const handleEntry = () => {
        const existingLearning = learnings.find((i) => i.title === learning.title);
        if (existingLearning) {
            const updatedLearnings = learnings.map((i) =>
                i.title === learning.title ? { ...i, time: i.time + learning.time } : i
            );
            setLearnings(updatedLearnings);
            //savedLearning(updatedLearnings);削除
        } else {
            const newLearnings = [...learnings, learning];
            setLearnings(newLearnings);
            //savedLearning(newLearnings)削除; 
        }
        setLearning({ title: "", time: 0 });
        onClose();
    }

    return (
        <>
            <Stack spacing={3}>
                <Button
                    colorScheme='blue'
                    variant='outline'
                    onClick={onOpen}
                >
                    新規登録
                </Button>
            </Stack>
            <Modal
                initialFocusRef={initialRef}
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>新規登録</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <FormControl>
                            <FormLabel>学習内容</FormLabel>
                            <Input
                                ref={initialRef}
                                name='newEntryTitle'
                                placeholder='学習内容'
                                value={learning.title}
                                onChange={(e) => {
                                    setLearning({ ...learning, title: e.target.value })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                name='newEntryTime'
                                placeholder='学習時間'
                                value={learning.time}
                                onChange={(e) => {

                                    setLearning({ ...learning, time: Number(e.target.value) })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{learning.title}</div>
                        <div>入力されている学習時間:{learning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (learning.title !== "" && learning.time > 0) {
                                    handleEntry()
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            登録
                        </Button>
                        <Button onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}
export default NewEntry


Edit.tsxです(コード全文を記載してます)。savedLearning関連の記載を削除します。

// /src/components/Edit.tsx
import React, { useRef, useState } from 'react';
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { FiEdit } from "react-icons/fi"
import { StudyData } from '../studyData';

type Props = {
    index: number
    learning: StudyData
    learnings: StudyData[]
    setLearnings: (learnings: StudyData[]) => void
    //savedLearning: (learnings: { title: string, time: number }[]) => void 削除
}

const Edit: React.FC<Props> = ({ learning, index, learnings, setLearnings /*savedLearning削除*/ }) => {
    const { isOpen, onOpen, onClose } = useDisclosure()
    const [error, setError] = useState("")
    const initialRef = useRef(null)
    const [localLearning, setLocalLearning] = useState(learning)

    const handleChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            title: e.target.value
        })
    }

    const handleChangeTime = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            time: Number(e.target.value)
        })
    }

    const handleUpdate = () => {
        const updatedLearnings = [...learnings];
        updatedLearnings[index] = { ...localLearning };
        setLearnings(updatedLearnings);
        //savedLearning(updatedLearnings); 削除
        onClose();
    }


    return (
        <>
            <Button variant='ghost' onClick={() => {
                setLocalLearning(learning)
                onOpen()
            }}><FiEdit color='black' /></Button>

            <Modal
                initialFocusRef={initialRef}
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>記録編集</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <FormControl>
                            <FormLabel>学習内容</FormLabel>
                            <Input
                                ref={initialRef}
                                placeholder='学習内容'
                                name='title'
                                value={localLearning.title}
                                onChange={handleChangeTitle}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                placeholder='学習時間'
                                name='time'
                                value={localLearning.time}
                                onChange={handleChangeTime}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{localLearning.title}</div>
                        <div>入力されている学習時間:{localLearning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>

                    <ModalFooter>
                        <Button
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (localLearning.title !== "" && localLearning.time > 0) {
                                    handleUpdate()
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            データを更新
                        </Button>
                        <Button onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Edit;


Delete.tsxです(コード全文を記載してます)。savedLearning関連の記載を削除します。

// /src/components/Delete.tsx
import React, { useRef } from 'react';
import { Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { MdDelete } from 'react-icons/md';
import { StudyData } from '../studyData';

type Props = {
    index: number
    learning: StudyData
    learnings: StudyData[]
    setLearnings: (learnings: StudyData[]) => void
    //savedLearning: (learnings: { title: string, time: number }[]) => void 削除
}

const Delete: React.FC<Props> = ({ learning, index, learnings, setLearnings /*savedLearning削除*/ }) => {
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

    const handleDelete = () => {
        setLearnings(learnings.filter((_, i) => i !== index));
        //savedLearning(learnings.filter((_, i) => i !== index)); 削除
        onClose();
    }

    return (
        <>
            <Button variant='ghost'
                onClick={onOpen}
            >
                <MdDelete color='black' /></Button>

            <Modal
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>データ削除</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <Box>
                            以下のデータを削除します。<br />
                            学習内容:{learning.title}、学習時間:{learning.time}
                        </Box>
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            ref={initialRef}
                            colorScheme='red'
                            variant='outline'
                            mr={3}
                            onClick={handleDelete}
                        >
                            削除
                        </Button>
                        <Button onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Delete;


続いて、App.tsxにSupabaseのテーブルデータ読込の機能を追加します。
App.tsxのコード全体は以下となります。

// /src/App.tsx
import { useEffect, useState } from "react";
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"//Spinner、インポート追加
import NewEntry from "./components/NewEntry";
import Edit from "./components/Edit";
import Delete from "./components/Delete";
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";//supabase呼出機能のインポート

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);//loading用のstateを追加
  const [error, setError] = useState<string>(""); // エラーメッセージ用のstateを追加

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .select('*');
    if (error) {
      console.error('Error fetching data:', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      setLearnings(data);
      setLoading(false);
    }
  };

  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);

  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems='center' justify='center' p={5}>
        <Card size={{ base: 'sm', md: 'lg' }}>
          <CardHeader>
            <Heading size='md' textAlign='center'>Learning Records</Heading>
          </CardHeader>
          <CardBody>
            <Box textAlign='center'>
              学習記録
              {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
              {error && <Box p={10} color='red'>{error}</Box>}{/*エラーであればエラー内容を表示*/}
                <TableContainer>
                  <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                    <Thead>
                      <Tr>
                        <Th>学習内容</Th>
                        <Th>時間()</Th>
                        <Th></Th>
                        <Th></Th>
                      </Tr>
                    </Thead>
                    <Tbody>
                      {learnings.map((learning, index) => (
                        <Tr key={index}>
                          <Td>{learning.title}</Td>
                          <Td>{learning.time}</Td>
                          <Td>
                            <Edit learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                          </Td>
                          <Td>
                            <Delete learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                          </Td>
                        </Tr>
                      ))}
                    </Tbody>
                  </Table>
                </TableContainer>
            </Box>
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>
            <Box p={25}>
              <NewEntry learnings={learnings} setLearnings={setLearnings} />
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  )
}

export default App


supabase呼出機能である、supabaseClient.tsxをインポートしてます。
また、DBデータ取得は時間がかかる場合がある為、データ取得状況のステート、loading,setLoadingと、DBからエラーが返った場合のエラー表示の為のステート、error,setErrorを新たに設けてます。

// /src/App.tsx

import { supabase } from "./supabaseClient";//supabase呼出機能のインポート

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);//loading用のstateを追加
  const [error, setError] = useState<string>(""); // エラーメッセージ用のstateを追加


テーブルデータの取得は、fetchLearningsで定義。DBからのデータ取得はタイムラグがある為、非同期通信のasync/awaitを実装します。

// /src/App.tsx
  
  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .select('*');
    if (error) {
      console.error('Error fetching data:', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      setLearnings(data);
      setLoading(false);
    }
  };

最初にloadingをtureにセットし、ローディング状態にstateを変更します。
Supabaseのlearning_recordテーブルから、全てのデータをselectで取得してます。データ取得後、setLearningsでlearningsに取得データを格納し、loadingをfalseにセットし、ローディング状態を解除します。エラーの場合は、setErrorでエラーメッセージをセットしてます。

続いて、ローカルストレージ利用時と同様に、ページを表示するタイミングでデータを持ってくるよう、useEffectでfetchLearningsを実行しています。

// /src/App.tsx

  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);


画面表示を行う、JSXの箇所に、ローディング状態、及びエラーの有無によって表示内容を変えています。ローディング中は、Chakra UIの<Spinner />でスピン状態のアニメーション表示、エラー時はエラー内容の表示、それ以外の場合は取得した学習データを表示してます。

// /src/App.tsx
              {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
              {error && <Box p={10} color='red'>{error}</Box>}{/*エラーであればエラー内容を表示*/}
                <TableContainer>
                  <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                    <Thead>
                      <Tr>
                        <Th>学習内容</Th>
                        <Th>時間()</Th>
                        <Th></Th>
                        <Th></Th>
                      </Tr>
                    </Thead>
                    <Tbody>
                      {learnings.map((learning, index) => (
                        <Tr key={index}>
                          <Td>{learning.title}</Td>
                          <Td>{learning.time}</Td>
                          <Td>
                            <Edit learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                          </Td>
                          <Td>
                            <Delete learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                          </Td>
                        </Tr>
                      ))}
                    </Tbody>
                  </Table>
                </TableContainer>

ローディング中及びエラー時の画面は以下のようになります。

データ取得が成功すれば以下画面となります。Supabaseのテーブル「learning_record」の初期セットしたデータが表示されるのが分かります。

4. データの更新

次にデータ更新機能を実装していきます。
まず、App.tsxにDBテーブルデータ更新用の関数、updateDbを定義します。
非同期通信のasync/awaitで実装します。

// /src/App.tsx
 
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

最初にloadingをtureにセットし、ローディング状態にstateを変更します。
Supabaseのlearning_recordテーブルに対し、マッチするidのtitleとtimeをupdateにより更新しています。データ更新後、fetchLearningsを呼び出し、更新後のテーブルデータを取得しApp.tsxに再レンダリング、及びローディング状態を解除してます。エラーの場合は、setErrorでエラーメッセージをセットしてます。

次に、編集画面コンポーネントのEdit.tsxに渡すPropsを以下のように変更します。

// /src/App.tsx

<Edit learning={learning} updateDb={updateDb} loading={loading} error={error} setError={setError} />
{/* 以前の内容  <Edit learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
updateDb = {updateDb} loading={loading} error={error} setError={setError}追加、learnings={learnings} setLearnings={setLearnings} index={index}は削除 */}

新たに、updateDb, loading, error, setErrorを渡しています。
また、learnings, setLearnings, indexは削除してます(DB更新時は、idを元に処理を行いますので、mapで生成されたindexは不要になります)

続いて、Edit.tsxの修正です。
まず型定義と、受け取るPropsの修正です。

// /src/components/Edit.tsx

type Props = {
    // index: number削除
    learning: StudyData
    //learnings: StudyData[] 削除
    //setLearnings: (learnings: StudyData[]) => void 削除
    updateDb: (learning: StudyData) => Promise<void>//追加
    loading: boolean//追加
    error: string//追加
    setError: (error: string) => void//追加
}

const Edit: React.FC<Props> = ({ learning, updateDb, loading, error, setError }) => { //index,learnings, setLearnings 削除、updateDb, loading, error, setError追加
    const { isOpen, onOpen, onClose } = useDisclosure()
    // const [error, setError] = useState("") 削除

App.tsxから渡されなくなったPropsの型定義削除と、Propsの設定を削除しています(index,learnings,setLearnings)。そして新たにApp.tsxより渡されるPropsの型定義とProps分割代入の設定追加しています(updateDb, loading, error, setError)
加えて、今までローカルステートで定義していた、const [error, setError] = useState(“”)はPropsとして受け取るので削除します。

次に、handleUpdateの修正です。以下の内容に変更します。

// /src/components/Edit.tsx
    const handleUpdate = async () => {
        await updateDb(localLearning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

以前と同様に、各フィールドが空でなければ、データ更新処理として、DB更新関数updateDbを実行します。なお、非同期通信のasync/awaitを利用してます。
ローディング状態が解除されれば編集画面のモーダルをクローズ、App.tsxに更新されたデータが表示される形です。
なお、特に必要は無いのですが、loading状態のスピンアニメは、モーダルと、App.tsxの双方で動作します。モーダルを閉じた後も、App.tsxのスピンアニメが続いてる場合が多いため、モーダルを閉じるのに、setTimeoutを設定し、ローディング解除後の0.5秒後にモーダルを閉じるようにしてみました。

これに伴い、handleUpdateの動作トリガとなるボタンについては、Chakura UIの isLoading にてローディング処理中はローディング表示する形としています。

// /src/components/Edit.tsx

<Button
    isLoading={loading} //追加
    loadingText='Loading' //追加
    spinnerPlacement='start' //追加
    colorScheme='blue'
    variant='outline'
    mr={3}
    onClick={() => {
        if (localLearning.title !== "" && localLearning.time > 0) {
            handleUpdate()
        }
        else setError("学習内容と時間を入力してください")
    }}
>


また、Cancelボタンをクリックしてモーダルを閉じる際は、errorを初期化する処理を追加しました。これをしないと、エラー表示された際、Cancelでモーダル閉じた後も、エラー内容が画面に表示され続ける為です。

// /src/components/Edit.tsx

<Button onClick={() => {
    setError("") //エラーを初期化してモーダルクローズ
    onClose()
}}>Cancel</Button>


これらにより、以下画面のような処理が実現できると思います。

Supabaseのテーブルデータも更新されていることが分かります。

現時点のApp.tsxとEdit.tsxの全体の内容は以下となります。

// /src/App.tsx
import { useEffect, useState } from "react";
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import NewEntry from "./components/NewEntry";
import Edit from "./components/Edit";
import Delete from "./components/Delete";
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>("");

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .select('*');
    if (error) {
      console.error('Error fetching data:', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      setLearnings(data);
      setLoading(false);
    }
  };

  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);

  //DB更新
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems='center' justify='center' p={5}>
        <Card size={{ base: 'sm', md: 'lg' }}>
          <CardHeader>
            <Heading size='md' textAlign='center'>Learning Records</Heading>
          </CardHeader>
          <CardBody>
            <Box textAlign='center'>
              学習記録
              {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
              {error && <Box p={10} color='red'>{error}</Box>}{/*エラーであればエラー内容を表示*/}
              <TableContainer>
                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit learning={learning} updateDb={updateDb} loading={loading} error={error} setError={setError} />
                        </Td>
                        <Td>
                          <Delete learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>
            <Box p={25}>
              <NewEntry learnings={learnings} setLearnings={setLearnings} />
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  )
}

export default App

// /src/components/Edit.tsx
import React, { useRef, useState } from 'react';
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { FiEdit } from "react-icons/fi"
import { StudyData } from '../studyData';

type Props = {
    learning: StudyData
    updateDb: (learning: StudyData) => Promise<void>
    loading: boolean
    error: string
    setError: (error: string) => void
}

const Edit: React.FC<Props> = ({ learning, updateDb, loading, error, setError }) => {
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)
    const [localLearning, setLocalLearning] = useState(learning)

    const handleChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            title: e.target.value
        })
    }

    const handleChangeTime = (e: React.ChangeEvent<HTMLInputElement>) => {
        setLocalLearning({
            ...localLearning,
            time: Number(e.target.value)
        })
    }

    const handleUpdate = async () => {
        await updateDb(localLearning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    return (
        <>
            <Button variant='ghost' onClick={() => {
                setLocalLearning(learning)
                onOpen()
            }}><FiEdit color='black' /></Button>

            <Modal
                initialFocusRef={initialRef}
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>記録編集</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <FormControl>
                            <FormLabel>学習内容</FormLabel>
                            <Input
                                ref={initialRef}
                                placeholder='学習内容'
                                name='title'
                                value={localLearning.title}
                                onChange={handleChangeTitle}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                placeholder='学習時間'
                                name='time'
                                value={localLearning.time}
                                onChange={handleChangeTime}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{localLearning.title}</div>
                        <div>入力されている学習時間:{localLearning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>

                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (localLearning.title !== "" && localLearning.time > 0) {
                                    handleUpdate()
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            データを更新
                        </Button>
                        <Button onClick={() => {
                            setError("") //エラーを初期化してモーダルクローズ
                            onClose()
                        }}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Edit;

5. データの新規登録

続いて、テーブルデータの新規追加機能を実装していきます。
まずは、App.tsxにDBへの新規登録機能、insertDbを定義します。同様に、非同期通信のasync/awaitで実装します。

// /src/App.tsx

//DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .insert([
        { title: learning.title, time: learning.time },
      ])
      .select();
    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示
    }
  }

最初にloadingをtureにセットし、ローディング状態にstateを変更します。
Supabaseのlearning_recordテーブルに対し、titleとtimeをinsertにより登録しています。idは自動付与の為、不要です。データ更新後、fetchLearningsを呼び出し、更新後のテーブルデータを取得しApp.tsxに再レンダリング、及びローディング状態を解除してます。エラーの場合は、setErrorでエラーメッセージをセットしてます。

次に、新規登録画面コンポーネントのNewEntry.tsxに渡すPropsを以下のように変更します。

// /src/App.tsx

<NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError} />
{/* insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError}追加 、setLearnings削除 */}

新たに、insertDb, updateDb, loading, error, setErrorを渡しています。
updateDbも渡すのは、登録内容に既存アイテムと重複があった場合の処理の為です。
また、setLearningsは使用しなくなるため、削除してます。

続いて、NewEntry.tsxの修正です。
まず型定義と、受け取るPropsの修正です。

// /src/components/NewEntry.tsx

type Props = {
    learnings: StudyData[]
    //削除 setLearnings: (learnings: StudyData[]) => void
    insertDb: (learning: StudyData) => Promise<void> //追加
    updateDb: (learning: StudyData) => Promise<void> //追加
    loading: boolean //追加
    error: string //追加
    setError: (error: string) => void //追加
}

const NewEntry: React.FC<Props> = ({ learnings, insertDb, updateDb, loading, error, setError }) => {
    //insertDb, updateDb, loading, error, setError追加、setLearnings削除
    const [learning, setLearning] = useState<StudyData>({ id: "", title: "", time: 0 })
    //const [error, setError] = useState("") 削除
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

新たにApp.tsxより渡されるPropsの型定義とProps分割代入の設定追加しています(insertDb, updateDb, loading, error, setError)
加えて、今までローカルステートで定義していた、const [error, setError] = useState(“”)はPropsとして受け取るので削除します。

次に、handleEntryの修正です。以下の内容に変更します。

// /src/components/NewEntry.tsx

    const handleEntry = async (data: StudyData) => { //変更
        if (learnings.some((l) => l.title === data.title)) {
            const existingLearning = learnings.find((l) => l.title === data.title);
            if (existingLearning) {
                existingLearning.time += data.time;
                await updateDb(existingLearning);
            }
        } else {
            await insertDb(data);
        }
        setLearning({ id: "", title: "", time: 0 });
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

ローカルストレージ格納版と同様に、新規入力した学習タイトルが既に存在する場合は、学習時間を加算した上で、データ更新処理としてDB更新関数updateDbを実行します。存在しない新規内容であれば、新規登録として、insertDb関数を実行します。なお、非同期通信のasync/awaitを利用してます。
ローディング状態が解除されれば編集画面のモーダルをクローズ、App.tsxに更新されたデータが表示される形です。Edit.tsxと同様に、setTimeoutを設定し、ローディング解除後の0.5秒後にモーダルを閉じるようにしています。

これに伴い、handleUpdateの動作トリガとなるボタンについては、Chakura UIの isLoading にてローディング処理中はローディング表示する形としています。また(元々からですが)入力データが空の場合のエラー処理も行っています。

// /src/components/NewEntry.tsx

<Button
    isLoading={loading}//追加
    loadingText='Loading'//追加
    spinnerPlacement='start'//追加
    colorScheme='blue'
    variant='outline'
    mr={3}
    onClick={() => {
        if (learning.title !== "" && learning.time > 0) {
            handleEntry(learning)
        }
        else setError("学習内容と時間を入力してください")
    }}
>

これでDBへの新規登録が行えるようになりました。以下の画面のようになります。

Supabaseのテーブルにも新規にデータが追加されていることが分かると思います。

現時点のApp.tsxとNewEntry.tsxの全体の内容は以下となります。

// /src/App.tsx
import { useEffect, useState } from "react";
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import NewEntry from "./components/NewEntry";
import Edit from "./components/Edit";
import Delete from "./components/Delete";
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>("");

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .select('*');
    if (error) {
      console.error('Error fetching data:', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      setLearnings(data);
      setLoading(false);
    }
  };

  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);

  //DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .insert([
        { title: learning.title, time: learning.time },
      ])
      .select();
    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DB更新
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems='center' justify='center' p={5}>
        <Card size={{ base: 'sm', md: 'lg' }}>
          <CardHeader>
            <Heading size='md' textAlign='center'>Learning Records</Heading>
          </CardHeader>
          <CardBody>
            <Box textAlign='center'>
              学習記録
              {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
              {error && <Box p={10} color='red'>{error}</Box>}{/*エラーであればエラー内容を表示*/}
              <TableContainer>
                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit learning={learning} updateDb={updateDb} loading={loading} error={error} setError={setError} />
                        </Td>
                        <Td>
                          <Delete learning={learning} index={index} learnings={learnings} setLearnings={setLearnings} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>
            <Box p={25}>
              <NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError} />
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  )
}

export default App

// /src/components/NewEntry.tsx
import { useRef, useState } from "react";
import { Box, Button, FormControl, FormLabel, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, useDisclosure } from "@chakra-ui/react";
import { StudyData } from "../studyData";

type Props = {
    learnings: StudyData[]
    insertDb: (learning: StudyData) => Promise<void>
    updateDb: (learning: StudyData) => Promise<void>
    loading: boolean
    error: string
    setError: (error: string) => void
}

const NewEntry: React.FC<Props> = ({ learnings, insertDb, updateDb, loading, error, setError }) => {
    const [learning, setLearning] = useState<StudyData>({ id: "", title: "", time: 0 })
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

    const handleEntry = async (data: StudyData) => { //変更
        if (learnings.some((l) => l.title === data.title)) {
            const existingLearning = learnings.find((l) => l.title === data.title);
            if (existingLearning) {
                existingLearning.time += data.time;
                await updateDb(existingLearning);
            }
        } else {
            await insertDb(data);
        }
        setLearning({ id: "", title: "", time: 0 });
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    return (
        <>
            <Stack spacing={3}>
                <Button
                    colorScheme='blue'
                    variant='outline'
                    onClick={onOpen}
                >
                    新規登録
                </Button>
            </Stack>
            <Modal
                initialFocusRef={initialRef}
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>新規登録</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <FormControl>
                            <FormLabel>学習内容</FormLabel>
                            <Input
                                ref={initialRef}
                                name='newEntryTitle'
                                placeholder='学習内容'
                                value={learning.title}
                                onChange={(e) => {
                                    setLearning({ ...learning, title: e.target.value })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>

                        <FormControl mt={4}>
                            <FormLabel>学習時間</FormLabel>
                            <Input
                                type='number'
                                name='newEntryTime'
                                placeholder='学習時間'
                                value={learning.time}
                                onChange={(e) => {

                                    setLearning({ ...learning, time: Number(e.target.value) })
                                }}
                                onFocus={() => setError("")}
                            />
                        </FormControl>
                        <div>入力されている学習内容:{learning.title}</div>
                        <div>入力されている学習時間:{learning.time}</div>
                        {error &&
                            <Box color="red">{error}</Box>}
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            colorScheme='blue'
                            variant='outline'
                            mr={3}
                            onClick={() => {
                                if (learning.title !== "" && learning.time > 0) {
                                    handleEntry(learning)
                                }
                                else setError("学習内容と時間を入力してください")
                            }}
                        >
                            登録
                        </Button>
                        <Button onClick={() => {
                            setError("") //エラーを初期化してモーダルクローズ
                            onClose()
                        }}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}
export default NewEntry

6. データの削除

最後に、テーブルデータの削除機能を実装していきます。
まずは、App.tsxにDBへの削除機能、deleteDbを定義します。これまでと同様に、非同期通信のasync/awaitで実装します。

 // /src/App.tsx
 
  //DBデータ削除
  const deleteDb = async (learning: StudyData) => {
    setLoading(true);
    const { error } = await supabase
      .from('learning_record')
      .delete()
      .eq('id', learning.id);
    if (error) {
      console.error('Error delete data:', error);
      setLoading(false);
    } else {
      console.log('delete', learning.id);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

loadingをtureにセットし、ローディング状態にstateを変更します。
Supabaseのlearning_recordテーブルに対し、マッチするidに対しdeleteによりデータを削除しています。データ更新後、fetchLearningsを呼び出し、更新後のテーブルデータを取得しApp.tsxに再レンダリング、及びローディング状態を解除してます。エラーの場合は、setErrorでエラーメッセージをセットしてます。

次に、削除画面コンポーネントのDelete.tsxに渡すPropsを以下のように変更します。

// /src/App.tsx
 
 <Delete learning={learning} deleteDb={deleteDb} loading={loading} />
{/* deleteDb={deleteDb} loading={loading}追加、index={index} learnings={learnings} setLearnings={setLearnings}削除 */}

新たに、deleteDb, loadingを渡しています。また不要となる、index, learnings, setLearningsを削除します(DB削除時は、idを元に処理を行いますので、mapで生成されたindexは不要になります)。

続いて、Delete.tsxの修正です。
まず型定義と、受け取るPropsの修正です。

// /src/components/Delete.tsx

type Props = {
    //index: number  削除
    learning: StudyData
    deleteDb: (learning: StudyData) => Promise<void> //追加
    loading: boolean //追加
    //learnings: StudyData[]  削除
    //setLearnings: (learnings: StudyData[]) => void 削除
}

const Delete: React.FC<Props> = ({ learning, deleteDb, loading }) => {
//index, learnings, setLearnings 削除、deleteDb, loading追加

App.tsxから渡されなくなったPropsの型定義削除と、Propsの設定を削除しています(index,learnings,setLearnings)。そして新たにApp.tsxより渡されるPropsの型定義とProps分割代入の設定追加しています(deleteDb, loading)

次に、handleDeleteの修正です。以下の内容に変更します。

// /src/components/Delete.tsx

    const handleDelete = async () => {
        await deleteDb(learning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

deleteDb関数にて、id(learning.id)を元に指定されたlearningデータを削除します。
ここまでと同様、setTimeoutを設定し、ローディング解除後の0.5秒後にモーダルを閉じるようにしています。

そして、handleUpdateの動作トリガとなるボタンについては、Chakura UIの isLoading にてローディング処理中はローディング表示する形としています。

// /src/components/Delete.tsx

<Button
    isLoading={loading}//追加
    loadingText='Loading'//追加
    spinnerPlacement='start' //追加
    ref={initialRef}
    colorScheme='red'
    variant='outline'
    mr={3}
    onClick={handleDelete}
>

これで削除機能が出来ました。
以下の画面の動きが実現されます。

Supabaseのテーブルからもデータが削除されたのが確認できます。

ここまでのApp.tsxとDelete.tsxの全体の内容は以下となります。

// /src/App.tsx
import { useEffect, useState } from "react";
import { Box, Card, CardBody, CardHeader, Flex, Heading, Spinner, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"
import NewEntry from "./components/NewEntry";
import Edit from "./components/Edit";
import Delete from "./components/Delete";
import { StudyData } from "./studyData";
import { supabase } from "./supabaseClient";

function App() {
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string>("");

  // Supabaseからデータを取得する関数
  const fetchLearnings = async () => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .select('*');
    if (error) {
      console.error('Error fetching data:', error);
      setError(`データの読込に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      setLearnings(data);
      setLoading(false);
    }
  };

  // useEffectを使ってコンポーネントのマウント時にデータを取得
  useEffect(() => {
    fetchLearnings();
  }, []);

  //DB新規登録
  const insertDb = async (learning: StudyData) => {
    setLoading(true);
    const { data, error } = await supabase
      .from('learning_record')
      .insert([
        { title: learning.title, time: learning.time },
      ])
      .select();
    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('insert', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DB更新
  const updateDb = async (learning: StudyData) => {
    setLoading(true);

    const { data, error } = await supabase
      .from('learning_record')
      .update({ title: learning.title, time: learning.time })
      .eq('id', learning.id)
      .select()

    if (error) {
      console.error('Error insert data:', error);
      setError(`データの更新に失敗しました、${error.message}`);
      setLoading(false);
    } else {
      console.log('update', data);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }

  //DBデータ削除
  const deleteDb = async (learning: StudyData) => {
    setLoading(true);
    const { error } = await supabase
      .from('learning_record')
      .delete()
      .eq('id', learning.id);
    if (error) {
      console.error('Error delete data:', error);
      setLoading(false);
    } else {
      console.log('delete', learning.id);
      fetchLearnings();//登録後、データ全体を再表示、ローディング解除
    }
  }
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };

  return (
    <>
      <Flex alignItems='center' justify='center' p={5}>
        <Card size={{ base: 'sm', md: 'lg' }}>
          <CardHeader>
            <Heading size='md' textAlign='center'>Learning Records</Heading>
          </CardHeader>
          <CardBody>
            <Box textAlign='center'>
              学習記録
              {loading && <Box p={10}><Spinner /></Box>} {/*ローティング中であれば<Spinner />を表示*/}
              {error && <Box p={10} color='red'>{error}</Box>}{/*エラーであればエラー内容を表示*/}
              <TableContainer>
                <Table variant='simple' size={{ base: 'sm', md: 'lg' }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit learning={learning} updateDb={updateDb} loading={loading} error={error} setError={setError} />
                        </Td>
                        <Td>
                          <Delete learning={learning} deleteDb={deleteDb} loading={loading} />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            <Box p={5}>
              <div>合計学習時間:{calculateTotalTime()}</div>
            </Box>
            <Box p={25}>
              <NewEntry learnings={learnings} insertDb={insertDb} updateDb={updateDb} loading={loading} error={error} setError={setError} />
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  )
}

export default App

// /src/components/Delete.tsx
import React, { useRef } from 'react';
import { Box, Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { MdDelete } from 'react-icons/md';
import { StudyData } from '../studyData';
type Props = {
    learning: StudyData
    deleteDb: (learning: StudyData) => Promise<void>
    loading: boolean
}

const Delete: React.FC<Props> = ({ learning, deleteDb, loading }) => {
    const { isOpen, onOpen, onClose } = useDisclosure()
    const initialRef = useRef(null)

    const handleDelete = async () => {
        await deleteDb(learning);
        if (!loading) {
            setTimeout(() => {
                onClose();
            }, 500);
        }
    }

    return (
        <>
            <Button variant='ghost'
                onClick={onOpen}
            >
                <MdDelete color='black' /></Button>

            <Modal
                isOpen={isOpen}
                onClose={onClose}
            >
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>データ削除</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody pb={6}>
                        <Box>
                            以下のデータを削除します。<br />
                            学習内容:{learning.title}、学習時間:{learning.time}
                        </Box>
                    </ModalBody>
                    <ModalFooter>
                        <Button
                            isLoading={loading}
                            loadingText='Loading'
                            spinnerPlacement='start'
                            ref={initialRef}
                            colorScheme='red'
                            variant='outline'
                            mr={3}
                            onClick={handleDelete}
                        >
                            削除
                        </Button>
                        <Button onClick={onClose}>Cancel</Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    )
}

export default Delete;

なお、補足ですが、プロジェクトルート配下のindex.html、こちらの<title>の箇所を必要に応じて変更してください。デフォルトだと、タイトルは、「Vite + React + TS」と設定されてますので、Learning Recorsds App等に変更しましょう。

7. GitHub、Firebase連携

一通り、コード作成が完了しましたので、リポジトリ作成とGitHubへのコミット(push)、そしてFirebaseとGitHub連携を行い、GitHubプッシュ時にFirebaseに自動デプロイされるようにします。
この一連の作業は、過去投稿した下記記事に詳細を記載してますので、ご参照ください。プロジェクト名等を読み替えていただくだけです。

なお、注意点ですが、

  • リポジトリは、Privateをおすすめします。.envの中にSupabase接続の機密情報が含まれるためです。
  • .gitgnoreファイルにGitHub同期除外対象として、.envを定義する事も可能ですが、この場合は、GitHubでのビルド、Firebaseでのデプロイはうまく行きませんので(.env情報が無いため)、ご注意ください。
    一見、ビルド及びデプロイは成功しますが、アプリが動きません(Supabase接続情報が見つからないとエラーになります)

Firebaseでデプロイして、ドメインの箇所からリリースされたアプリケーションを確認してみましょう。以下画面のようにアプリが表示されれば公開完了です!

学習記録アプリ、これで完了です。

チュートリアル:学習記録アプリシリーズ


2 Responses

コメントを残す

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

AD




TWITTER


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