SupabaseのSlackクローンアプリのサンプルをTypeScript & Redux Toolkit & React Router で書き換える(Supabase Databaseの操作)

2021.09.17

💻
TECH

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 など一式が作成されます。

Untitled

ローカルで Supabase を立ち上げている場合

ここで、注意なのですが、ローカルで Supabase を立ち上げて開発する際に、上記のサンプルからテーブルを作成すると、認証周りが機能しなくなります(supabase cli version:0.5.0 現在)。

上記の SQL を実行すると、 public.users というテーブルが作成されるのですが、これが悪さをします。

supabase init で作られた .supabase/postgres/auth-schema.sql に user を publicauth の順で探すように、以下の通り設定されているのが原因のようです。

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 ページ

型定義を行いましたので、実際にデータを取得して表示していきます。

大まかな方針として

  1. 存在する全ての Channel を取得し表示する
  2. チャンネルをクリックするとチャンネルの URL に遷移する
  3. 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件が取得できています。

Untitled


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 のメッセージが取得できるはずです。

実際に以下のようにサンプルデータが取得できました。

Untitled

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>
  );
};

このような見た目になります。

Untitled

左に表示されている 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 テーブルにメッセージが挿入されます。

また、このように画面にも表示されるはずです!

Untitled

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 ボタンをクリックして、追加を行ってみると以下のように新しいチャンネルが表示されると思います。

Untitled

最新メッセージの受信

ここまでで、現在登録されているメッセージを取得し、また、新たなメッセージを送信することができるようになりました。

ただしお気づきの通り、このままではメッセージを受け取るためには毎回リロードを行わなければなりません。

実は、リアルタイムにデータを取得することも Supabase の機能を用いて実現できます!今回は長くなったので、また次回の記事に回したいと思います。

まとめ

今回は Supabase Database に対しするデータの読み書きを試すために、チャットアプリを作っていきました。もはや Supabase の紹介とは言い難いですが、どこかで役に立つ部分があると幸いです。

今回のソースコードはこちらに置いていますので、よければみてみてください。

ありがとうございました 🙇🏻‍♂️


Homeへ戻る
profile picture

Kosuke Kihara

I'm a Web Developer👑 who shares tips on Tech, Productivity, Design, and much more!

Kohsuk11KOHSUKkohsuk.tech@gmail.com