🍆

【supabase-auth-helper】Supabase + Next.jsでCSRで認証後にtransitionを実装するときのメモ

2022/05/18に公開

Supabase + Next.js でクライアントサイドで認証を実装する際にはsupabase-auth-helperを使うと大変便利です。認証が完了した後には関係要素を Transition を使って表示したい、というケースがあるかと思います。(Zennさんのログインボタンのようなイメージです。)

完成イメージ:
▼非ログイン
https://gyazo.com/296573c0789e21096237e7be116e8ac1
▼ログイン済み
https://gyazo.com/7af2777b38cc5699f8efee27badf6619

このような動きを "supabase-auth-helper" を絡ませて実装する際に、下記のような問題が発生します。

現象例:「エリアを検索」というボタンだけご注目ください。
▼パターンA(ページ遷移では問題は起きないが、タブを切り替える度にtransitionが動いてしまって目障り)
https://gyazo.com/ef3a283d87f08e520313ff02a5796eeb

▼パターンB(タブ切り替えでは問題は起きないが、ページ遷移の度にtransitionが動いてしまう重症)
https://gyazo.com/c113501cb9a739af9f8c735be7e56125

このような動きになるのは

  • タブを切り替え度に都度useUser()の処理が行われレンダリングに影響を与えてしまう。

というsupabase-auth-helper側の処理の影響があります。transitionを諦めるか、SSRで判定すれば問題は秒で解決します。しかし、サーバー側に処理を増やしたくないので、どうにかしてクライアント側で認証したいということで試行錯誤しました。

なお、useUser() は初回アクセス(URLを叩いてアクセスすることを指します)に非同期的に処理が行われる部分があるので、useEffectを使って実装してみましたが、今度はページ遷移の度に動いてしまいました。なかなか厄介な動きをするため、transitionを諦めようと思いましたが、Recoilを絡ませるとうまく行きましたので、ここに残しておきます。

※supabase-auth-helperの内部的な結果とビューが切り離されることになるかと思いますので、多少の矛盾が生じても気にしない、という前提です。(この実装方法でも、もちろんリロードすれば再度正しく判定されるはずです。)

問題が起きているときの実装はどんな記述をしていたのか。

コードを残していないのでイメージができるように軽く説明します。

useUser() で取得できる isLoadinguser を使って CSSTransition に渡していました。 isLoading が true になったら要素を表示し、 user にデータが入っていればログイン状態、データがなければ非ログインの表示としていました。

タブを切り替えると useUser()が動いてしまい、 isLoadingが true -> false に切り替わるという動きがあるため、タブ切り替えの度にtransitionが動くという問題がありました。

Recoilの状態管理ファイルを用意

この管理の手法は下記記事を参考にさせて頂いております。
https://engineering.linecorp.com/ja/blog/line-sec-frontend-using-recoil-to-get-a-safe-and-comfortable-state-management/

なお、今回は useEffect の中で取り扱う関係から useMountedState() というものを噛ませています。(ホントはここでセット用の関数を用意したかったのですが、エラーになったので苦肉の策として...。)

/src/states/RecoilKeys.ts
export enum RecoilAtomKeys {
  IS_USER_MOUNTED_FIRST_TIME = 'isUserMountedFirstTime' // ユーザーの初回マウントのステータス
}
/src/states/userState.ts
import { atom, useRecoilState, useRecoilValue } from 'recoil'
import { RecoilAtomKeys } from './RecoilKeys'

const userMountedFirstTimeAtom = atom({
  key: RecoilAtomKeys.IS_USER_MOUNTED_FIRST_TIME,
  default: false
})

export const currentUser = {
  useMountedState: () => { // useEffectで状態セットを可能にするための苦肉の策
    return useRecoilState(userMountedFirstTimeAtom)
  },

  useIsMountedFirstTime: () => { // 現在の状態を取得
    const isUserMountedFirstTime = useRecoilValue(userMountedFirstTimeAtom)
    return isUserMountedFirstTime
  }
}

URLを直接叩いた後に初回の認証の完了状態をRecoilに記録する

_app.tsxで共通処理を記述しています。この辺りの内容はZennの創造主であるcatnoseさんの記事を参考にさせて頂きました。

useUser()の isLoading の値を監視し、認証状態が確定したことを表す false を感知したタイミングで先程のRecoilで管理している userMountedFirstTimeAtomcurrentUser.useMountedState() 経由で状態を上書きしています。

グローバルな部分で状態を保持しますので、useUser()特有の

  • ページ遷移したとき
  • タブを切り替えたとき
    にtrue / false が切り替わってしまう、という問題から解放することができます!

https://zenn.dev/catnose99/articles/2169dae14b58b6

_app.tsx
import { supabaseClient } from '@supabase/supabase-auth-helpers/nextjs'
import { UserProvider, useUser } from '@supabase/supabase-auth-helpers/react'
import '../styles/globals.css'
import type { AppProps } from 'next/app'

import Head from 'next/head'
import { useEffect } from 'react'
import { RecoilRoot } from 'recoil'

import { currentUser } from './../src/states/userState'

const AppInit = () => {
  const { isLoading } = useUser()
  const [isMounted, setIsMounted] = currentUser.useMountedState()

  useEffect(() => {
    if (!isMounted && !isLoading) {
      setIsMounted(true)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoading])

  return <></>
}

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <RecoilRoot>
        <UserProvider supabaseClient={supabaseClient}>
          <Head>
            <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1,user-scalable=no" />
          </Head>
          <Component {...pageProps} />
          <AppInit />
        </UserProvider>
      </RecoilRoot>
    </>
  )
}

export default MyApp

認証結果待ち中はスケルトンスクリーン、結果が確定したらログイン・非ログインに応じて表示を切り替え

ログイン状態に応じて切り替えを行うコンポーネントファイル
なお、スケルトンスクリーンの実装に際しては、先人たちが擦り切ったネタですが https://zenn.dev/masa5714/scraps/731d5bdcb2cfb2 この考え方でrem単位で実装すると高さを決め打ちで実装できて超便利でした!(PC版は作ってないので、PC版を作ったことによる影響については検証していません。)

/src/components/utils/cta/AreasSettingCTA.tsx
import { useUser } from '@supabase/supabase-auth-helpers/react'
import Link from 'next/link'
import ContentLoader from 'react-content-loader'
import { CSSTransition } from 'react-transition-group'
import { currentUser } from '../../../states/userState'

export const AreaSettingsCallToAction = () => {
  const { user } = useUser()
  const isMounted = currentUser.useIsMountedFirstTime()

  /*
  ローディング中はスケルトンスクリーンを表示する
  完了後、ログインチェックを行う
  ◆ ログイン中なら AreaSettingCallToActionsForMember
  ◆ ログインしてないなら AreaSettingCallToActionForGuest
  */
  return (
    <>
      <div className={`relative w-full h-[3.2rem]`}>
        <CSSTransition classNames={`loaded-transition`} in={!isMounted} timeout={150} unmountOnExit>
          <div className={`absolute top-0 left-0 w-full h-full -z-10`}>
            <ContentLoader viewBox="0 0 375 32">
              <rect x="0" y="0" rx="0" ry="0" width="375" height="32" />
            </ContentLoader>
          </div>
        </CSSTransition>

        <CSSTransition classNames={`loaded-transition`} in={isMounted} timeout={150} unmountOnExit>
          <div>
            {!isMounted ? (
              <></>
            ) : (
              <>{!user ? <AreaSettingCallToActionForGuest /> : <AreaSettingCallToActionsForMember />}</>
            )}
          </div>
        </CSSTransition>
      </div>
    </>
  )
}

const AreaSettingCallToActionForGuest = () => {
  return (
    <>
      <p>ログインしてないときの表示</p>
    </>
  )
}

const AreaSettingCallToActionsForMember = () => {
  return (
    <>
      <p>ログインしているときの表示</p>
    </>
  )
}

まとめ

ログイン状況によって切り替えを行いたいコンポーネントファイルの中で

const { user } = useUser()
const isMounted = currentUser.useIsMountedFirstTime()

この2行を呼び出せば判定切り替えの準備は整います。

そもそも supabase-auth-helper でこんな問題起きるなんてお前の実装方法に問題があるんじゃないの?っていうお話があればコメントにてお叱り頂きたいです!

Discussion