Supabase の Slack クローンアプリのサンプルを TypeScript & Redux Toolkit & React Router で書き換える(Supabase Database の操作編)
[2022/08/15 更新]
この記事を更新してから Supabase も進化しました。
こちらで新たに VsCode の Remote Containers 拡張機能を使った開発環境の立ち上げ方法を紹介しています。
開発環境の構築方法については、そちらの記事の方が最新の情報に基づいていますので併せてご覧ください。
Supabase をローカルで立ち上げて React から使ってみる 〜 Redux Toolkit と React Router を用いた認証編 〜では Supabase を用いた認証周りの実装を行いました。
今回は、Supabase.io がサンプルとして用意しているNext.js Slack Cloneを参考に、Redux Toolkit, React Router を用いた実装に書き換えてみました。前回(実際の前回記事は補足記事なので正確には前々回)作った認証編のソースコードを流用するのでベースは Next.js ではなく Create React App になります。
前回の認証編のソースコードはこちらのリポジトリにおいていますので、よければ確認してみてください。
今回作成したもののソースコードはこちらです。
ちなみに、引き続きローカルで Supabase を立ち上げて開発していますが、supabase.ioからログインしてプロジェクトを作成する方法でも、プロジェクトの URL とanon
キーさえ .env
に設定すれば、特に変わりありませんので、そちらでも試すことは可能です(本当はそちらの方が楽です)。
各種ライブラリのバージョン
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@material-ui/core": "^5.0.0-beta.4",
"@reduxjs/toolkit": "^1.6.1",
"@supabase/supabase-js": "^1.22.2",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"history": "^5.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.13.0",
"react-redux": "^7.2.4",
"react-router-dom": "^6.0.0-beta.2",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
認証編と同じく、Material-UI と React Route はベータバージョンを使っています。
データベースの作成
まずはデータベースを作成していきます。
公式のリポジトリの Examples に Next.js Slack Clone というものがあります。その名の通り、Next.js を用いた Slack のクローンアプリなのですが、この中にfull-schema.sqlというファイルがあります。
これを用いて必要なテーブルを作成します。任意の SQL クライアントなどにコピペして実行してください。以下のようなテーブル・Functions・Data types など一式が作成されます。
ローカルで Supabase を立ち上げている場合
ここで、注意なのですが、ローカルで Supabase を立ち上げて開発する際に、上記のサンプルからテーブルを作成すると、認証周りが機能しなくなります(supabase cli version:0.5.0 現在)。
上記の SQL を実行すると、 public.users
というテーブルが作成されるのですが、これが悪さをします。
supabase init
で作られた .supabase/postgres/auth-schema.sql
に user を public
→ auth
の順で探すように、以下の通り設定されているのが原因のようです。
ALTER ROLE postgres SET search_path = "$user", public, auth;
以下のように逆にしたものを一発実行すれば、解決します(あるいは、public に users
以外の別名で作る方法もありますが、今回は簡単のために以下でいきます)。
-- Before
-- ALTER ROLE postgres SET search_path = "$user", auth, public;
-- After
ALTER ROLE postgres SET search_path = "$user", auth, public;
※ supabase cli のリポジトリをみると直してあるみたいですが、公式で案内されているインストール方法で 2021 年 9 月 17 日現在インストールされる CLI では上記の問題が残っています。
TypeScript のための Database Type の生成
(この節は私の環境ではうまくいきませんでしたが、メモとして残しておきます。)
TypeScript で使う場合は、テーブルの型情報が欲しいです。
Supabase では、
https://your-project.supabase.co/rest/v1/?apikey=your-anon-key
にアクセスすることで OpenAPI スキーマを取得することができます。
また、公式ドキュメントのGenerating Typesのページにopenapi-typescriptというツールを使った型生成を行う方法が案内されています。
OpenAPI のスキーマから TypeScript のインターフェースを作成することができます。
以下のように使います。
npx openapi-typescript https://your-project.supabase.co/rest/v1/?apikey=your-anon-key --output types/supabase.ts
早速使ってみます。
Docker でローカル起動した Supabase を用いている場合のプロジェクトの URL デフォルトでは http://localhost:8000 ですので以下のように変更して実行します。
npx openapi-typescript "http://localhost:8000/rest/v1/?apikey=your-anon-key" --output types/supabase.ts
ちなみに URL はダブルクォーテーション(シングルも可)で囲っておかないとエラーになりました。
実行すると以下のようなファイルが生成されます。
types/supabase.ts
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/": {
get: {
responses: {
/** OK */
200: unknown;
};
};
};
"/channels": {
get: {
parameters: {
query: {
/** Filtering Columns */
select?: parameters["select"];
/** Ordering */
order?: parameters["order"];
/** Limiting and Pagination */
offset?: parameters["offset"];
/** Limiting and Pagination */
limit?: parameters["limit"];
...
...
export interface definitions {
/** Topics and groups. */
channels: { [key: string]: unknown };
/** Individual messages sent by each user. */
messages: { [key: string]: unknown };
/** Application permissions for each role. */
role_permissions: { [key: string]: unknown };
/** Application roles for each user. */
user_roles: { [key: string]: unknown };
/** Profile data for each user. */
users: { [key: string]: unknown };
}
生成はされましたが、 definitions
が全て { [key: string]: unkown }
となってしまっており、ほぼ無意味です。。。
私のやり方がまずいのか、まだ対応されていないのかわかりませんが、今回は諦めて自力で定義することにします。
TypeScript のための Database Type の定義
気を取り直してテーブルの列情報の型定義を行います。
types/supabase.ts
export type AppRole = "admin" | "moderator";
export type AppPermission = "channels.delete" | "messages.delete";
export type UserStatus = "ONLINE" | "OFFLINE";
export interface definitions {
/** Topics and groups. */
channels: {
id: number;
inserted_at: Date;
slug: string;
created_by: string;
};
/** Individual messages sent by each user. */
messages: {
id: number;
inserted_at: Date;
message?: string;
user_id: string;
channel_id: string;
};
/** Application permissions for each role. */
role_permissions: {
id: number;
role: AppRole;
permission: AppPermission;
};
/** Application roles for each user. */
user_roles: {
id: number;
user_id: string;
role: AppRole;
};
/** Profile data for each user. */
users: {
id: string;
username?: string;
status?: UserStatus;
};
}
上記説明の OpenAPI スキーマから生成された定義に寄せて定義しています。生成がうまくいったら入れ替えたいと思います。
Channels ページ
型定義を行いましたので、実際にデータを取得して表示していきます。
大まかな方針として
- 存在する全ての Channel を取得し表示する
- チャンネルをクリックするとチャンネルの URL に遷移する
- 2で選択した Channel 内のメッセージを全て表示する。
という流れを想定して実装していきます。
チャンネルページへの遷移
チャンネルを選択すると channels/1
とか channels/2
などの channels/${チャンネルID}
という形の URL に遷移するという機能を実装します。
前回の認証機能実装時にはログアウトボタンのみをおいたサンプルページを表示していた、Channels ページへの遷移について以下のように変更します。
app.tsx
// Before
<PrivateRoute path="/channels" element={<Channels />} />
// After
<PrivateRoute path="/channels">
<Route element={<Channels />} />
<Route path="/:channelId" element={<Channels />} />
</PrivateRoute>
/:channelId
となっている部分が変数になっており、この部分を React Router で取り出して、取得するチャンネル ID として利用します。
/channels/1
などでアクセスできるようになっているはずです。
また、チャンネル ID が指定されていない /channels
のみの URL でもチャンネルページ自体表示できようにしています。
Supabase Database からデータを取得
次に実際にデータを取得して表示していきます。
Channel の一覧の取得
初めに channels
テーブルから channel を全て取得していきます。
supabase-jsにデータベースからデータを取得するための仕組みが用意されています。
channels
テーブルから全て取得する場合は以下のように記述できます。
const { body } = await supabase.from("channels").select("*");
SQL で書くとこうですね。
select * from channels
つまり、
from(テーブル名)
という風に参照するテーブル名を
select('列名')
という風に取得する列名を
指定しています。
ところで、TypeScript の場合は body
変数が any
でない方が嬉しいです。
以下のように from
に型引数を指定すれば、戻り値の型を指定することができます。
const { body } = await supabase.from<Channels>("channels").select("*");
これで、 body
の型は Channels[] | null
になります(Channels
型は 次の節で定義します)。
createAsyncThunk で Channel 一覧を取得
supabase でのデータの取得の仕方がわかったので、Redux Toolkit の createAsyncThunk を使って実装してみます。
また、エンティティ操作を楽にするため、ceateEntityAdapterも使います。
以下のように実装しました。
redux/channels/slice.ts
import {
createAsyncThunk,
createEntityAdapter,
createSlice,
} from "@reduxjs/toolkit";
import { supabase } from "services/supabase";
import { definitions } from "types/supabase";
export const channelsAdapter = createEntityAdapter<definitions["channels"]>({
selectId: (channel) => channel.id,
sortComparer: (a, b) => a.slug.localeCompare(b.slug),
});
export type Channel = definitions["channels"];
export const fetchChannels = createAsyncThunk(
"channels/fetchChannels",
async (_: void, thunkAPI) => {
try {
const { body, error } = await supabase
.from<Channel>("channels")
.select("*");
if (error) thunkAPI.rejectWithValue(error);
return body;
} catch (error) {
thunkAPI.rejectWithValue(error);
}
}
);
export const channelsSlice = createSlice({
name: "channels",
initialState: channelsAdapter.getInitialState(),
extraReducers: (builder) => {
builder.addCase(fetchChannels.fulfilled, (state, action) => {
if (action.payload) {
channelsAdapter.setAll(state, action.payload);
}
});
},
});
これを、利用できるように configureStore
に設定します。
export const store = configureStore({
reducer: {
[authSlice.name]: authSlice.reducer,
[channelsSlice.name]: channelsSlice.reducer, // <-追加
},
});
これで、Redux 側は OK です。
これを利用するカスタムフックを作成します。
コンポーネントがマウントされた時に呼ばれれば良いので以下のように、 useEffect
内で呼び出します。
hooks/use-data-loader.tsx
import { useDispatch } from "react-redux";
import { fetchChannels } from "redux/channels/slice";
export const useDataLoader = () => {
const dispatch = useDispatch();
// Load initial data and set up listeners
useEffect(() => {
dispatch(fetchChannels());
}, [dispatch]);
};
fetchChannels
を呼ぶタイミングは、Channels ページを開いた時です。
最後にこれを Channels ページで呼び出します。
components/channels/index.tsx
import React from 'react';
import { useDataLoader } from 'hooks/use-data-loader';
export const Channels: React.VFC = () => {
...
useDataLoader();
...
これで、Channels ページを開いた時にデータが取得できているはずです。
Redux Devtools を使って確認すると、このように初期データとして挿入されている2件が取得できています。
Messages の取得
Channels が取得できたので、次はチャンネルごとのメッセージを取得できるようにします。
まず以下のようにfetchMessages
を定義します。
redux/channels/slice.ts
export const fetchMessages = createAsyncThunk(
"messages/fetchMessages",
async (channelId: string, thunkAPI) => {
try {
const { body, error } = await supabase
.from<Message>("messages")
.select(`*, author:user_id(*)`)
.eq("channel_id", channelId)
.order("inserted_at", { ascending: true });
if (error) thunkAPI.rejectWithValue(error);
return body;
} catch (error) {
thunkAPI.rejectWithValue(error);
}
}
);
ここでクエリの部分ですが、以下のように定義しています。
const { body, error } = await supabase
.from<Message>("messages")
.select(`*, author:user_id(*)`)
.eq("channel_id", channelId)
.order("inserted_at", { ascending: true });
channels テーブルにはなかった部分として、
eq
で列に対する検索条件を
order
でソート条件を
指定しています。
また、 select
には列名以外に、外部キーを指定することで参照テーブルを JOIN して取得することができます。
select 内の、 author:user_id(*)
の部分で、message テーブルの user_id
列を指定しています。
user_id
は public.users テーブルの主キーである id
への外部キーとなっているため、これに一致したものが JOIN されて帰ってきます。
つまり、戻り値の body
はこのようになります。
{
id: number;
inserted_at: Date;
message: string;
user_id: string;
channel_id: string;
author: {
id: number;
username?: string;
status?: UserStatus
}
}
author は public.users テーブルの列になっています。
ですので、 Message
型は以下のように定義しました。
export type Message = definitions['messages'] & {
author: definitions['users'] | null;
};
公式の解説はこちらです。ちょっと、説明足りない気がしますが。。。どこかに纏まった説明あるんでしょうか?
fetchMessages
ができましたので、これを使って Slice を定義します。
redux/messages/slice.ts
import {
createAsyncThunk,
createEntityAdapter,
createSlice,
} from "@reduxjs/toolkit";
import { supabase } from "services/supabase";
import { definitions } from "types/supabase";
export type Message = definitions["messages"] & {
author: definitions["users"] | null;
};
export const messagesAdapter = createEntityAdapter<Message>({
selectId: (message) => message.id,
});
export const fetchMessages = createAsyncThunk(
"messages/fetchMessages",
async (channelId: number, thunkAPI) => {
try {
const { body, error } = await supabase
.from<Message>("messages")
.select(`*, author:user_id (*)`)
.eq("channel_id", channelId)
.order("inserted_at", { ascending: true });
if (error) thunkAPI.rejectWithValue(error);
return body;
} catch (error) {
thunkAPI.rejectWithValue(error);
}
}
);
export const messagesSlice = createSlice({
name: "messages",
initialState: messagesAdapter.getInitialState(),
extraReducers: (builder) => {
builder.addCase(fetchMessages.fulfilled, (state, action) => {
if (action.payload) {
messagesAdapter.setAll(state, action.payload);
}
});
},
});
と実装しました。
これを、 いつも通り configureStore
に設定し、
redux/store.ts
export const store = configureStore({
reducer: {
...
[messagesSlice.name]: messagesSlice.reducer,
...
先程のカスタムフックに追加します。ここでカスタムフックの引数に channelId
を追加しておきます。
hooks/use-data-loader.tsx
export const useDataLoader = (props: { channelId: string }) => {
...
// Update when the route changes
useEffect(() => {
const channelNum = parseInt(props.channelId);
if (!isNaN(channelNum)) dispatch(fetchMessages(channelNum));
}, [dispatch, props.channelId]);
...
}
Channel ごとのメッセージの取得
開いているチャンネルに応じたメッセージを実際に取得できるようにしていきます。
まず、開いているチャンネルのchannelId
を取得したいのですが、これは React Router で行います。app.tsx
で Router を設定したときにpath
に:channleId
と指定した部分を取得することができます。
具体的には以下のようにuseParams
フックを使います。これと先ほどchannelId
を引数にとるように変更した useDataLoader
フックを組み合わせて以下のようにします。
components/channels/index.tsx
import { useParams } from 'react-router-dom';
export const Channels: React.VFC = () => {
const { channelId } = useParams();
useDataLoader({ channelId: channelId });
...
}
これでchannels/1
ページを開くと、 channelId = 1
のメッセージが取得できるはずです。
実際に以下のようにサンプルデータが取得できました。
author
(users)も一緒に取得できています。
User の取得
ログイン中のユーザーの public.users
テーブルのデータを取得します。
以下の通りチャンネルやメッセージとほとんど変わりません。
redux/user/slice.ts
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { supabase } from "services/supabase";
import { definitions } from "types/supabase";
export type UserState = {
user: definitions["users"] | null;
};
const initialState: UserState = {
user: null,
};
export const fetchUser = createAsyncThunk(
"user/fetchUser",
async (userId: string, thunkAPI) => {
try {
const { body, error } = await supabase
.from<definitions["users"]>("users")
.select("*")
.eq("id", userId);
if (error) thunkAPI.rejectWithValue(error);
if (!body) {
return null;
}
return body[0];
} catch (error) {
thunkAPI.rejectWithValue(error);
}
}
);
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchUser.fulfilled, (state, action) => {
if (action.payload) {
state.user = action.payload;
}
});
},
});
他と同様に configureStore
に追加し、use-data-loader.tsx
に追加していきます。
redux/store.ts
...
export const store = configureStore({
reducer: {
[authSlice.name]: authSlice.reducer,
[messagesSlice.name]: messagesSlice.reducer,
[channelsSlice.name]: channelsSlice.reducer,
[userSlice.name]: userSlice.reducer,
},
});
...
hooks/use-data-loader.tsx
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { authUserSelector } from 'redux/auth/selector';
import { fetchUser } from 'redux/user/slice';
export const useDataLoader = (props: { channelId: string }) => {
const dispatch = useDispatch();
...
...
const user = useSelector(authUserSelector);
useEffect(() => {
if (user) {
dispatch(fetchUser(user?.id));
}
}, [dispatch, user]);
};
これで必要なデータの取得は完了です。
Channel と Message の表示
データを取得して Redux のストアに入れることができたので、表示してみましょう。
今回は createEntityAdapter
を使ったので、これが生成する selector を利用して Redux 内のストアの中身を取得することができます。
例えば、Channel では、
import { RootState } from "redux/store";
import { channelsAdapter } from "./slice";
export const channelsSelectors = channelsAdapter.getSelectors<RootState>(
(state) => state.channels
);
とすることで、selector のセットを取得できます。
取得した selector のセットの中には、
selectIds: (state: V) => EntityId[]
selectEntities: (state: V) => Dictionary<T>
selectAll: (state: V) => T[]
selectTotal: (state: V) => number
selectById: (state: V, id: EntityId) => T | undefined
などが一式揃っています。
例えば以下のように useSelector と一緒に使うことでデータを取得できます。
import { useSelector } from 'react-redux';
export const AwesomeComponent = () => {
const channels = useSelector(channelsSelectors.selectAll);
const messages = useSelector(messagesSelectors.selectAll);
...
};
チャンネルとメッセージの表示
実際にメッセージを表示してみます。
以下のような形で実装しました。
components/channels/index.tsx
import React from "react";
import {
Box,
Button,
Divider,
Drawer,
List,
ListItem,
ListItemButton,
Typography,
} from "@material-ui/core";
import { useDataLoader } from "hooks/use-data-loader";
import { Link, useParams } from "react-router-dom";
import { Messages } from "./message";
import { useAuth } from "hooks/use-auth";
import { useSelector } from "react-redux";
import { channelsSelectors } from "redux/channels/selector";
import { Channel } from "redux/channels/slice";
import { userSelector } from "redux/user/selector";
const ChannelItem: React.VFC<{ channel: Channel }> = ({ channel }) => {
return (
<ListItem disablePadding>
<ListItemButton component={Link} to={`/channels/${channel.id}`}>
{channel.slug}
</ListItemButton>
</ListItem>
);
};
export const Channels: React.VFC = () => {
const { channelId } = useParams();
useDataLoader({ channelId: channelId });
const { signOut } = useAuth();
const handleSignOutClick = async () => {
await signOut();
};
const user = useSelector(userSelector);
const channels = useSelector(channelsSelectors.selectAll);
const drawerWidth = 240;
return (
<Box
sx={{
display: "flex",
alignItems: "center",
height: "100%",
}}
>
<Drawer
variant="permanent"
anchor="left"
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
},
}}
>
<List>
<ListItem
sx={{
bgcolor: (theme) => theme.palette.primary.light,
color: (theme) => theme.palette.primary.contrastText,
}}
>
<Typography variant="body1" noWrap>
{user?.username}
</Typography>
</ListItem>
<ListItem>
<Button onClick={handleSignOutClick} fullWidth>
Sign Out
</Button>
</ListItem>
<Divider />
<ListItem
sx={{
bgcolor: (theme) => theme.palette.primary.light,
color: (theme) => theme.palette.primary.contrastText,
}}
>
<Typography variant="body1">Channels</Typography>
</ListItem>
<ListItem>
<Button fullWidth>New Channel</Button>
</ListItem>
<Divider />
{channels &&
channels.map((channel) => (
<ChannelItem key={channel.id} channel={channel} />
))}
</List>
</Drawer>
<Box
sx={{
flexGrow: 1,
bgcolor: "background.default",
p: 3,
overflowY: "auto",
height: "100%",
}}
>
<Messages />
</Box>
</Box>
);
};
components/channels/messages.tsx
import React from "react";
import { useForm, Controller, SubmitHandler } from "react-hook-form";
import {
Box,
Paper,
Stack,
styled,
TextField,
Typography,
} from "@material-ui/core";
import { useDispatch, useSelector } from "react-redux";
import { messagesSelectors } from "redux/messages/selector";
import { addMessage } from "redux/messages/slice";
import { useParams } from "react-router-dom";
const Message = styled(Paper)(({ theme }) => ({
padding: theme.spacing(1),
textAlign: "left",
color: theme.palette.text.secondary,
width: "100%",
marginBottom: theme.spacing(2),
}));
type FormInput = {
message: string;
};
export const Messages: React.VFC = () => {
const messages = useSelector(messagesSelectors.selectAll);
return (
<Box
sx={{
height: "100%",
width: "100%",
position: "relative",
}}
>
<Stack>
{messages.map((message) => (
<Message key={message.id}>
<Typography variant="caption">
{message.author?.username}
</Typography>
<Typography variant="body1">{message.message}</Typography>
</Message>
))}
</Stack>
<TextField
id="message"
name="message"
fullWidth
placeholder="Send a message"
sx={{ position: "absolute", bottom: 0 }}
value={field.value}
onChange={field.onChange}
ref={field.ref}
/>
</Box>
);
};
このような見た目になります。
左に表示されている public、random がチャンネル名でこれをクリックするとそのチャンネルのメッセージが表示されます。
データを Insert する
表示はできましたので、メッセージの送信とチャンネルの作成ができるようにします。
まずはメッセージの送信をできるようにしてみましょう。
React Hook Form と Material Ui の Textfield で入力欄を実装
まず送信用のテキスト入力欄で、文字を入力してエンターキー押下で送信し、入力欄をクリアするという流れを実装します。
ここではReact Hook Formを利用します。
前回の記事のログイン画面でも使っていますが、今回は送信後入力欄をクリアするという点が異なります。
以下のように実装するとエンター押下時にクリアできます。
components/channels/messages.tsx
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
type FormInput = {
message: string;
};
export const Messages = () => {
...
const { control, handleSubmit, reset } = useForm({
defaultValues: { message: '' }, // <- ポイント③
});
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (data.message) {
console.log(data.message);
}
reset(); // <- ポイント①
};
return (
...
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="message"
control={control}
defaultValue=""
render={({ field }) => (
<TextField
id="message"
name="message"
fullWidth
placeholder="Send a message"
sx={{ position: 'absolute', bottom: 0 }}
value={field.value}
onChange={field.onChange}
ref={field.ref} // <- ポイント②
/>
)}
/>
</form>
)
}
入力をクリアするためのポイントですが、ポイント ① がフォームのステートをクリアするための関数です。
今回 UI フレームワーク(Material UI)を利用している関係上、この reset
だけを入れてもうまく行かないようで、ポイント ② がその対応手段です。こちらのドキュメントの Rules の一つ目に書いてありました。
ポイント ③ は入れておかないと、Material UI の関係で Console にエラーが出るので入れました。
メッセージの追加
では実際にデータベースにメッセージを追加できるようにしていきます。
メッセージ取得時と同じように createAsyncThunk
で実装していきます。
redux/messages/slice.ts
export const addMessage = createAsyncThunk<
Message | null | undefined,
{ message: string; channel_id: string }, // <- ポイント①
{ state: RootState }
>('messages/addMessage', async ({ message, channel_id }, thunkAPI) => {
try {
const author = thunkAPI.getState().user.user; // <- ポイント②
if (!author) return null;
const { body, error } = await supabase
.from<definitions['messages']>('messages')
.insert([{ message, channel_id, user_id: author.id }]); // <- ポイント③
if (error) thunkAPI.rejectWithValue(error);
if (!body) return null;
return { ...body[0], author } as Message; // <- ポイント④
} catch (error) {
thunkAPI.rejectWithValue(error);
}
});
export const messagesSlice = createSlice({
...
...
extraReducers: (builder) => {
...
builder.addCase(addMessage.fulfilled, (state, action) => {
if (action.payload) {
messagesAdapter.addOne(state, action.payload);
}
});
},
});
これは少しトリッキーになっています。
ポイント ① あたりで createAsyncThunk
の型引数を色々と与えています。
これはポイント ② を使うためなのですが、3つ目の型引数で state
の型を決めてやらないと、getState
の戻り値が unkown
になってしまいます。
ポイント ② が何かというと、他のステートを参照するための方法で、これを使うことで引数として渡さずに、createAsyncThunk
の中で直接 Redux のストア内のデータを参照することができます。
ポイント ③ の insert
は Supabase の Database にデータを挿入するためのもにで、中に追加するオブジェクトのリストを入れれば挿入されます。当然ですが、Not Null 制約が入っている列を指定しない場合などはエラーになります。
ポイント ④ では戻り値に author
を追加しています。もともと messages
のデータを取得する際に、Join して取得していたため、ここでも author
をくっつけて返してあげることで、 messagesSlice
の中では単純に返した値をストアに追加することができています。
では以下のように、 dispatch
とともに用いて、メッセージの送信を行えるようにします。
components/channels/messages.tsx
export const Messages: React.VFC = () => {
...
const dispatch = useDispatch();
const { channelId } = useParams();
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (data.message && channelId) {
dispatch(
addMessage({
message: data.message,
channel_id: channelId,
})
);
}
reset();
};
...
}
これで入力欄に文字を書いて Enter キーを押すと messages
テーブルにメッセージが挿入されます。
また、このように画面にも表示されるはずです!
Channel の追加
引き続いて Channel を追加できるようにします。
redux/channels/slice.ts
export const addChannel = createAsyncThunk(
'channels/addChannel',
async ({ slug, user_id }: { slug: string; user_id: string }, thunkAPI) => {
try {
const { body, error } = await supabase
.from<definitions['channels']>('channels')
.insert([{ slug, created_by: user_id }]);
if (error) thunkAPI.rejectWithValue(error);
if (!body) {
return null;
}
return { ...body[0], author: null } as definitions['channels'];
} catch (error) {
thunkAPI.rejectWithValue(error);
}
}
);
export const channelsSlice = createSlice({
...
extraReducers: (builder) => {
...
builder.addCase(addChannel.fulfilled, (state, action) => {
if (action.payload) {
channelsAdapter.addOne(state, action.payload);
}
});
},
});
components/channels/index.tsx
export const Channels: React.VFC = () => {
...
const slugify = (text: string) => {
return text
.toString()
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w-]+/g, '') // Remove all non-word chars
.replace(/--+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
};
const user = useSelector(userSelector);
const handleNewChannel = async () => {
const slug = prompt('Please enter your name');
if (slug && user) {
await dispatch(addChannel({ slug: slugify(slug), user_id: user.id }));
}
};
return (
...
<Button onClick={handleNewChannel} fullWidth>
New Channel
</Button>
...
);
};
メッセージの追加とほとんど変わりません。
NEW CHANNEL ボタンをクリックして、追加を行ってみると以下のように新しいチャンネルが表示されると思います。
最新メッセージの受信
ここまでで、現在登録されているメッセージを取得し、また、新たなメッセージを送信することができるようになりました。
ただしお気づきの通り、このままではメッセージを受け取るためには毎回リロードを行わなければなりません。
実は、リアルタイムにデータを取得することも Supabase の機能を用いて実現できます!今回は長くなったので、また次回の記事に回したいと思います。
まとめ
今回は Supabase Database に対しするデータの読み書きを試すために、チャットアプリを作っていきました。もはや Supabase の紹介とは言い難いですが、どこかで役に立つ部分があると幸いです。
今回のソースコードはこちらに置いていますので、よければみてみてください。
ありがとうございました 🙇🏻♂️