[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を利用しています。
今回は前編として、Supabase環境の準備、DB連携、DBデータの表示機能の実装までを記載しています。
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は以下内容で設定してください。
Foreign keysは不要です。
次に作成したtableにデータを作成しておきます。
左メニューのTable Editorから「learning_record」(作成したテーブル)を選択し、insert → insert lowをクリックします。
右側にデータの内容を入力するエリアが表示されますので、データを入力します。ひとまず、初期データとして下記のReactとTypeScriptの2つセットします(idは自動でセットされますので、入力不要です)。
「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
}
続いて、この型定義を、各コンポーネントに反映、インポートします。
まず、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」の初期セットしたデータが表示されるのが分かります。
Supabaseでセットしたテーブルデータをロードし、表示させることが出来ました。前編はひとまず、ここまでとしたいと思います。
後編は、Supabaseテーブルデータの新規登録、編集、削除機能を実装したいと思います。
この記事が気に入ったらサポートをしてみませんか?