Supabaseをローカルで立ち上げてReactから使ってみる 〜 Redux Toolkit と React Router を用いた認証編 〜

2021.08.24

💻
TECH

Supabase をローカルで立ち上げて React から使ってみる 〜 Redux Toolkit と React Router を用いた認証編 〜

本記事で作成したアプリの全ソースコードをこちらに公開しています。


[2022/08/15 更新]

この記事を更新してから Supabase も進化しました。

こちらで新たに VsCode の Remote Containers 拡張機能を使った開発環境の立ち上げ方法を紹介しています。

開発環境の構築方法については、そちらの記事の方が最新の情報に基づいていますので併せてご覧ください。


Supabase とは?

Supabaseは Firebase の代替となることを目指して開発されているバックエンドサービスです。オープンソースのソフトウェアを利用して開発されており、Supabase そのものもオープンソースになっています。

Firebase の代替とは言うものも1対1で入れ替え可能なものを目指しているわけではなく、特に Database にリレーショナルデータベース(Postgres)を用いているのが特徴です。

ローカルでの開発

Supabase.io でホストされているサービスを用いて開発することもできますが、Supabase は自身のサーバーやローカルで立ち上げて開発することもできます。

自身でホストした場合まだ UI が用意されていませんので、気軽に開発したい場合は Supabase.io を使う方が楽かもしれないですね(現在開発中途のことなので期待!)

今回は、ローカルで立ち上げた Supabase と React を使って簡単なアプリを作ってみます!

(公式のドキュメント)

作成するもの

今回は Supabase Auth を利用して認証機能を実装していきます。

Redux Toolkit を使ってログイン状態を管理し、React Router で画面遷移を制御するところまで作ります。

基本的にはログインとログアウトができるだけのアプリです。

Sign In

アプリの全ソースコードはこちらに公開しています。

Supabase の起動

必要なもの

Supabase CLI のインストール

$ npm install -g supabase

Supabase の初期化

あらかじめ、Docker を起動しておきます。

# 任意のディレクトリを作成/移動
$ mkdir supabase-sample
$ cd supabase-sample

# 初期化
$ supabase init

supabase init コマンドを叩くとポート番号を色々聞かれます。特にこだわりがなければそのままエンターキーを押していくとInitializing project...と表示されて作成が開始されます。

処理が完了すると以下のようなキーや URL の一覧が表示されますのでどこかにメモしておきます。

$ supabase init
✔ Port for Supabase URL: · 8000
✔ Port for PostgreSQL database: · 5432
✔ Port for email testing interface: · 9000
✔ Project initialized.
Supabase URL: http://localhost:8000
Supabase Key (anon, public): eyJ0eXAiOiJKV1Q...
Supabase Key (service_role, private): eyJ0eXAiOiJKV1Q...
Database URL: postgres://postgres:postgres@localhost:5432/postgres
Email testing interface URL: http://localhost:9000

Run supabase start to start local Supabase.

また.supabaseフォルダがコマンドを実行したディレクトリに作成されます。中身には supabase を起動するのに必要なdocker-compose.ymlなどがありました。

Supabase の起動

.supabase が作成されたディレクトリで以下のコマンドを実行します。

$ supabase start

これで、Supabase の Auth や Database のサービスがローカルで利用可能になります。

データベース

supabase start で Postgres が起動しているのでpsql や SQL クライアントアプリ等で接続可能です。少し覗いてみます。今回はDBeaverを使ってみます。

supabase init コマンド完了時の出力に PostgreSQL の Connection URI が表示されています。

postgresql://{ユーザー:パスワード}@{ホスト名}:{ポート番号}/{DB名}
postgres://postgres:postgres@localhost:5432/postgres

接続設定を確認して、入力します。

Untitled

接続してみると、このようにデフォルトでスキーマやロールなどが設定されています。

Untitled

テーブルについても、Auth に必要な users 等がデフォルトで作成されています。

Untitled

React アプリから Supabase を利用する

Supabase の設定が完了したので、React でアプリを作って接続してみます。

Supabase の Auth のサービスを使って、ログイン周りの仕組みを備えたアプリを作ってみましょう。

React での Supabase の使い方はこちらのチュートリアルが参考になります(他にも Angular, Vue, Flutter などいろんなフレームワークでチュートリアルが用意されています)。

アプリの作成

create-react-app で通常通りプロジェクトを作成して起動します。

$ npx create-react-app supabase-react-sample --template typescript
$ cd chat-app
$ yarn start

特に必須ではないですが、楽をするためにちょっとだけtsconfig.jsonをいじっておきます。

tsconfig.json

{
  "compilerOptions": {
    ...,
    "baseUrl": "src",
  },
  ...
}

絶対パスで import するための設定です(Create React App - Absolute Imports)。

supabase-js のインストール

次にsupabase-jsをプロジェクトに追加します。

yarn add @supabase/supabase-js

また、supabase initコマンド実行時の出力にあったキーを.envファイルに書いておきます。

.env

REACT_APP_SUPABASE_URL=http://localhost:8000
REACT_APP_SUPABASE_ANON_KEY=eyJ0eXAiOiJKV1Q...

SUPABASE_ANON_KEY には先程の出力の Supabase Key (anon, public) のキーを貼り付けておきます。

出力のもう一方の Supabase Key (service_role, private) は開発者のロールで Supabase の API を叩く時に使うもののようです。privateと書いているので公開してはいけないキーですね。(ローカルで開発している分には関係ないですが)

アプリから .env を読み込む

.envを読み込んでアプリ内で利用できるようにします。

config/env.ts

export const Env = {
  SUPABASE_URL: process.env.REACT_APP_SUPABASE_URL || "",
  SUPABASE_ANON_KEY: process.env.REACT_APP_SUPABASE_ANON_KEY || "",
} as const

Supabase client を初期化する

Supabase の Auth や Database などのサービスに接続するための Supabase client を取得します。

services/supabase/supabase-client/index.ts

import { createClient } from "@supabase/supabase-js"
import { Env } from "config/env"

const supabaseUrl = Env.SUPABASE_URL
const supabaseAnonKey = Env.SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

services/supabase/index.ts

export * from "./supabase-client"

これで、Supabase を利用することができます!

ログイン画面の作成

ログイン画面を作成していきましょう。

初めにライブラリをいくつか追加しておきます。

Material UI と React Router はお試しにベータのバージョンを使ってみます(最近個人的に触ってみたからというだけの理由です)。

# Material UI
yarn add @material-ui/core@next @emotion/react @emotion/styled

# React Router
yarn add history react-router-dom@next

# Redux Toolkit
yarn add @reduxjs/toolkit react-redux

# React Hook Form
yarn add react-hook-form

Supabase Auth を使った認証と Redux によるステートの管理

認証情報を管理するために Redux をセッティングしていきます。

まず認証情報のストアを定義ます。

また、上で export した Supabase のクライアントと Redux Toolkit の createAsyncThunk を使って

Sign In, Sign Out の処理も記述し、 createSliceextraReducers に追加します。

redux/auth/slice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit"
import { Session } from "@supabase/supabase-js"
import { signIn, signOut } from "./action"

export type AuthState = {
  session: Session | null
  loading: boolean
  error: Error | null | undefined
}

const initialState: AuthState = {
  session: null,
  loading: false,
  error: null,
}

type SetSessionPayload = {
  session: Session | null
}

export const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    setSession: (state, action: PayloadAction<SetSessionPayload>) => ({
      ...state,
      session: action.payload.session,
    }),
  },
  extraReducers: builder => {
    // signIn
    builder.addCase(signIn.pending, state => ({
      ...state,
      loading: true,
    }))
    builder.addCase(signIn.fulfilled, (state, action) => ({
      ...state,
      loading: false,
      session: action.payload,
    }))
    builder.addCase(signIn.rejected, (state, action) => ({
      ...state,
      loading: false,
      session: null,
      error: action.payload,
    }))

    // signOut
    builder.addCase(signOut.pending, state => ({
      ...state,
      loading: true,
    }))
    builder.addCase(signOut.fulfilled, state => ({
      ...state,
      loading: false,
      session: null,
      error: null,
    }))
    builder.addCase(signOut.rejected, (state, action) => ({
      ...state,
      loading: false,
      error: action.payload,
    }))
  },
})

redux/auth/action.ts

import { createAsyncThunk } from "@reduxjs/toolkit"
import { Session } from "@supabase/supabase-js"
import { supabase } from "services/supabase"

export const signIn = createAsyncThunk<
  Session | null,
  string,
  { rejectValue: Error }
>("auth/signIn", async (email, thunkApi) => {
  const { error, session } = await supabase.auth.signIn({ email })
  if (error) {
    return thunkApi.rejectWithValue(error)
  }

  return session
})

export const signOut = createAsyncThunk<void, void, { rejectValue: Error }>(
  "auth/signOut",
  async (_, thunkApi) => {
    const { error } = await supabase.auth.signOut()
    if (error) {
      return thunkApi.rejectWithValue(error)
    }
  }
)

今回 Magic Link を使ったパスワードのいらないログイン方法を使うため、Sign Up は用意しません。

Supabase では email & password の認証方法はもちろん、Google、Github などのアカウントを使った OAuth 認証もサポートしています。詳しくはこちらをご覧ください。

email & password で認証する場合は、詳細は省きますが以下のように、引数に両方渡す形にするだけで OK です。

// before
const { error, session } = await supabase.auth.signIn({ email })

// after
const { error, session } = await supabase.auth.signIn({
  email: "email@example.com",
  password: "password",
})

次にこれらの Auth 関連処理を呼び出すカスタムフックも定義しておきます。

hooks/use-auth.tsx

import React from "react"
import { useDispatch } from "react-redux"
import * as asyncActions from "redux/auth/action"

export const useAuth = () => {
  const dispatch = useDispatch()

  const signIn = React.useCallback(
    async (email: string) => {
      await dispatch(asyncActions.signIn(email))
    },
    [dispatch]
  )

  const signOut = React.useCallback(async () => {
    await dispatch(asyncActions.signOut())
  }, [dispatch])

  return {
    signIn,
    signOut,
  }
}

ルートのストアを定義

redux/store.ts

import { configureStore } from "@reduxjs/toolkit"
import { authSlice } from "./auth/slice"

export const store = configureStore({
  reducer: {
    [authSlice.name]: authSlice.reducer,
  },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

auth のセレクタを定義

redux/auth/selector.ts

import { RootState } from "../store"

// 認証済みかどうか
export const isAuthenticatedSelector = (state: RootState) =>
  state.auth.session != null

ストアの Provider を定義

この Provider をアプリのルートに置いて全体を囲めば、アプリのどこからでもストアにアクセスすることができます。後に、App.tsx で利用します。

redux/provider.tsx

import React from "react"
import { Provider } from "react-redux"
import { store } from "./store"

type Props = {
  children: React.ReactNode
}

export const StoreProvider: React.VFC<Props> = ({ children }) => {
  return <Provider store={store}>{children}</Provider>
}

React Router のセッティング

Redux の Store に保存した Supabase の認証情報にしたがってルーティングできるようにします。

ログイン済みユーザーのみがアクセスできる PrivateRoute を定義します。 未ログイン時は Sign In ページに遷移します。

routes/private-route.tsx

import React from "react"
import { useSelector } from "react-redux"
import { Navigate, RouteProps, Route } from "react-router-dom"
import { isAuthenticatedSelector } from "redux/auth/selector"

export const PrivateRoute: React.VFC<RouteProps> = props => {
  const isAuthenticated = useSelector(isAuthenticatedSelector)

  if (!isAuthenticated) {
    return <Navigate to="/signin" />
  }

  return <Route {...props} />
}

ログインページなど認証していない時だけアクセスできる PublicRoute を定義します。 ログイン済みなら/channelsに遷移します。

routes/public-route.tsx

import React from "react"
import { useSelector } from "react-redux"
import { Navigate, RouteProps, Route } from "react-router-dom"
import { isAuthenticatedSelector } from "redux/auth/selector"

export const PublicRoute: React.VFC<RouteProps> = props => {
  const isAuthenticated = useSelector(isAuthenticatedSelector)

  if (isAuthenticated) {
    return <Navigate to="/channels" />
  }

  return <Route {...props} />
}

再アクセス時、メール確認時の認証情報取得

何も対処しないと、画面をリロードするたびに当然再度アプリは初めから実行され、せっかく取得した Redux のストア内の認証情報もクリアされてしまいます。また、上で説明した通り Magic Link を使ったパスワードなしのログインを行うため、メールアドレスの確認がされたかどうかで、認証済みかどうかが変わってきます。

これに対処するために、認証情報のリスナーを作成します。

services/supabase/auth-listener/index.tsx

import React from "react"
import { useDispatch } from "react-redux"
import { authSlice } from "redux/auth/slice"
import { supabase } from "services/supabase/supabase-client"

type Props = {
  children: React.ReactNode
}

export const AuthListener: React.VFC<Props> = ({ children }) => {
  const dispatch = useDispatch()
  const session = supabase.auth.session()

  React.useEffect(() => {
    if (session) {
      // すでにログインしている場合
      dispatch(authSlice.actions.setSession({ session }))
    }

    // メールの確認等で認証された場合
    supabase.auth.onAuthStateChange((_event, _session) => {
      dispatch(authSlice.actions.setSession({ session: _session }))
    })
  }, [dispatch, session])

  return <>{children}</>
}

services/supabase/index.ts

export * from "./supabase-client"
export * from "./auth-listener" // 追加

ルーティング

ルーティングの処理を実装します。各 Routeelement にはとりあえずダミーを入れておきます。

App.tsx

import React from "react"
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { StoreProvider } from "redux/provider"
import { PublicRoute } from "routes/public-route"
import { AuthListener } from "services/supabase"
import { PrivateRoute } from "./routes/private-route"

const App: React.VFC = () => {
  return (
    <StoreProvider>
      <AuthListener>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<div>Home</div>} />
            <PublicRoute path="/signin" element={<div>Sign In</div>} />
            <PrivateRoute path="/channels" element={<div>Channels</div>} />
            <Route path="*" element={<div>Not Found</div>} />
          </Routes>
        </BrowserRouter>
      </AuthListener>
    </StoreProvider>
  )
}

export default App

これで以下のようなルーティング機能が実装されているはずです。

  • / ・・・Home ページ、よくあるランディングページを想定、誰でも参照可能。
  • /signin ・・・Sign In ページ、認証前のみ参照可能。
  • /channels ・・・アプリのメインメージを想定、認証後のみ参照可能。
  • * ・・・Not Found ページ、上で定義していないパスにアクセスした時に表示。誰でも参照可能。

試しに http://localhost:3000 でそれぞれのパスにアクセスすれば、それぞれの element の内容が表示されるはずです。

また、まだログインしていないので、 /channels にアクセスすると、 /signin にリダイレクトされます。

Sign In ページの作成

いよいよ認証ページを作成していきます。

まずはアカウントを新規作成する Sign In ページを作成します。

React Hook Form も使ってみます。

components/signup/index.tsx

import React from "react"
import { useForm, Controller, SubmitHandler } from "react-hook-form"
import {
  Box,
  Button,
  Container,
  TextField,
  Typography,
} from "@material-ui/core"
import { useAuth } from "hooks/use-auth"

type FormInput = {
  email: string
}

export const Signin: React.VFC = () => {
  const { control, handleSubmit } = useForm()
  const { signIn } = useAuth()

  const onSubmit: SubmitHandler<FormInput> = async data => {
    if (data.email) {
      await signIn(data.email)
    }
  }

  return (
    <Container component="main" maxWidth="xs">
      <Box
        sx={{
          marginTop: 8,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
        }}
      >
        <Typography component="h1" variant="h5">
          Sign In
        </Typography>
        <Box
          component="form"
          noValidate
          sx={{ mt: 1 }}
          onSubmit={handleSubmit(onSubmit)}
        >
          <Controller
            name="email"
            control={control}
            defaultValue=""
            rules={{
              required: "This field is required.",
            }}
            render={({ field, fieldState }) => (
              <TextField
                margin="normal"
                required
                fullWidth
                id="email"
                label="Email Address"
                name="email"
                autoFocus
                value={field.value}
                onChange={field.onChange}
                helperText={fieldState.error?.message}
              />
            )}
          />
          <Button
            type="submit"
            fullWidth
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            Sign In
          </Button>
        </Box>
      </Box>
    </Container>
  )
}

App.tsx/signinを指定しているルートの element をelement={<Signin />}と忘れずに変更します。

すると次のような画面になると思います。

Untitled

一度試しにメールアドレスを入力して SIGN IN ボタンを押してみます。

何も起こらないと思います。 パスワードのない認証方法ではメールアドレスの確認を行って初めてログインができるからです。

メールアドレスの確認

では、メールアドレスはどこで確認するのでしょう。 Supabase はローカルで起動しているだけですし、何もせずに本物のメールが送られているわけもありません。

Supabase はローカル起動時にもメールアドレスの確認を行う仕組みを用意してくれています!

supabase init を実行した時に Email testing interface URL: http://localhost:9000という記述があったことを思い出してください!

http://localhost:9000 にアクセスしてみると、以下のような画面が表示されます。

Untitled

Inbucket というメール送信をテストするオープンソースのソフトウェアが supabase start をした際に一緒にホストされています。

ではmailbox 欄に先程サインインに用いたメールアドレスを入力して Enter して確認してみます。

Untitled

画像のように、メールボックスが開かれ、届いたメールを確認することができます。

Your Magic Linkの方の Log In リンクをクリックしてみます。

すると http:localhost:3000 に遷移すると思います。これで認証も完了しました!

/channels にアクセスしても /signin に戻されることもなくなります。

/channels の画面を作成

メイン画面となる /channels を作っておきます。といっても、長くなってきましたので、ひとまずログアウトボタンのみ置いておきます。次回以降で作っていきたいと思います。

components/channels

import React from "react"
import { Button } from "@material-ui/core"
import { useAuth } from "hooks/use-auth"

export const Channels: React.VFC = () => {
  const { signOut } = useAuth()

  const handleClick = async () => {
    await signOut()
  }

  return (
    <Button onClick={handleClick} variant="contained">
      Sign Out
    </Button>
  )
}

/channels の遷移先を上のコンポーネントに差し替えておきます。

App.tsx/channelsを指定しているルートの element をelement={<Channels />}に変更します。

ちなみに、一度ログアウトすると、次回ログイン時にはまた送信されるリンクをクリックしないと認証されません。少し面倒ですが、パスワードがないので当然ですね。

リダイレクト関係の調整

メール確認で認証が完了することは確認できましたが、まだいくつか問題があります。

  1. Sign In 時は、メールを確認するようにユーザーに促す必要がある。
  2. メールの Magic Link クリック後に遷移する先は、 /channels にしたい。
  3. 認証後にリロードした時に一瞬ログイン画面が見える。

これらを直していきます。

Sign In 時は、メールを確認するようにユーザーに促す

確認を促すメッセージを表示する画面を実装して、 Sign In ボタン押下時に遷移するようにしておきます。

/components/check-email

import React from "react"
import { Box, Container, Typography } from "@material-ui/core"
import { Link } from "react-router-dom"

export const CheckEmail: React.VFC = () => {
  return (
    <Container component="main" maxWidth="xs">
      <Box
        sx={{
          marginTop: 8,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
        }}
      >
        <Typography variant="h5">Check your email for login link!</Typography>
        <Link to="/signin">
          <Typography variant="body2">Back to sign in page.</Typography>
        </Link>
      </Box>
    </Container>
  )
}

components/signin.tsx

import React from 'react';
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import {
  Box,
  Button,
  Container,
  TextField,
  Typography,
} from '@material-ui/core';
import { useAuth } from 'hooks/use-auth';
import { useNavigate } from 'react-router'; // 追加

type FormInput = {
  email: string;
};

export const Signin: React.VFC = () => {
  const { control, handleSubmit } = useForm();
  const { signIn } = useAuth();
  const navigate = useNavigate(); // 追加

  const onSubmit: SubmitHandler<FormInput> = async (data) => {
    if (data.email) {
      await signIn(data.email);
      navigate('/check-email'); // 追加
    }
  };

...

App.tsx

import React from "react"
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { CheckEmail } from "components/check-email"
import { Channels } from "components/channels"
import { Signin } from "components/signin"
import { StoreProvider } from "redux/provider"
import { PublicRoute } from "routes/public-route"
import { AuthListener } from "services/supabase"
import { PrivateRoute } from "./routes/private-route"

const App: React.VFC = () => {
  return (
    <StoreProvider>
      <AuthListener>
        <BrowserRouter>
          <Routes>
            ...
            {/* 以下を追加 */}
            <PublicRoute path="/check-email" element={<CheckEmail />} />
            ...
          </Routes>
        </BrowserRouter>
      </AuthListener>
    </StoreProvider>
  )
}

export default App

簡易的ですが、 Sign In ボタンクリック後 このような画面が表示されます。

Untitled

メールの Magic Link クリック後の遷移先を /channels

こちらは、 supabase.auth.signin の第二引数のオプションに redirectTo を指定することで解決です。

redux/auth/action.ts

import { createAsyncThunk } from "@reduxjs/toolkit"
import { Session } from "@supabase/supabase-js"
import { supabase } from "services/supabase/supabase-client"

export const signIn = createAsyncThunk<
  Session | null,
  string,
  { rejectValue: Error }
>("auth/signIn", async (email, thunkApi) => {
  const { error, session } = await supabase.auth.signIn(
    { email },
    { redirectTo: "http://localhost:3000/channels" } // これを追加
  )
  if (error) {
    return thunkApi.rejectWithValue(error)
  }

  return session
})

これでメールのリンクをクリックした時に /channels に遷移できます。

リロード時にログイン画面が一瞬見える点を修正

Redux の Auth ステートの sessionnull で初期化しているのが原因でした。

Auth のリスナーで session が取得できるまでの一瞬の間、未認証判定になってしまっていました。

以下のように修正すれば解決です。

redux/auth/slice.ts

const initialState: AuthState = {
  session: supabase.auth.session(), // 初期値を変更
  loading: false,
  error: null,
}

supabase.auth.session() はローカルストレージから現在のセッションを復元するようなので、これを Redux のステートの初期化時に入れておけば、認証済みならリロード時にも最初から session が取れているというわけです。

まとめ

以上で Supabase をローカルで起動して、React から認証機能を利用するサンプルが完成です。

次回以降、データベースを利用する部分についても書いていきたいと思います。

何かアドバイス等もあれば、コメントください!


KOHSUK

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

Code snippets licensed under MIT, unless otherwise noted.

Content & Graphics © 2022, KOHSUK