🔖

Cognitoの状態管理でRecoilとJotaiを試してみた

2022/04/21に公開

(2022年11月25日追記)
私の本が株式会社インプレス R&Dさんより出版されました。この記事の内容も含まれています。イラストは鍋料理さんの作品です。猫のモデルはなんとうちのコです!

https://www.amazon.co.jp/dp/B0BMPZW444/

感想を書いていただけるととても嬉しいです!

(2022年8月3日追記)この記事の内容はこちらの本でも読めます。

https://zenn.dev/sikkim/books/how_to_create_api_sales_service

はじめに

前回の記事から3週間ほど経過し、Cognitoの認証周りの実装がだいぶこなれてきました。新たに下記の内容が実現できるようになったので、忘れないうちにアウトプットしておきます。

  • サインインしていない状態で認証が必要な画面にアクセスしようとするとサインイン画面へ遷移する
  • サインイン済みの状態で認証が必要な画面にアクセスした場合は当該画面が表示される
  • サインイン済みの状態を保持する(パブリックな画面を表示したあとで認証が必要な画面を表示しても認証は不要)

画面キャプチャをお見せした方がわかりやすいと思います。

サインインせずに認証が必要な画面にアクセスしようとすると...

ログイン画面へ遷移する

サインイン済みの状態で認証が必要な画面にアクセスしようとすると...

認証が必要な画面へ遷移する

APIキーの自動生成や表示も実装済みですが、長くなるので別の記事に載せる予定です。

実装内容

パブリックなページと認証が必要なページでそれぞれ共通のレイアウトを作成する

まず共通処理をまとめるため、パブリックなページ(ランディングページなどの認証不要でアクセスできるページ)と認証が必要なページ用に、それぞれレイアウトを用意しました。パブリックなページから説明します。

例としてプライバシーポリシーページと利用規約を用います。以前の実装はこちらです。

src/routes/PrivacyPolicy.tsx
import { VFC } from 'react';
import Header1 from '../components/Header1';
import PrivacyPolicyContent from '../components/PrivacyPolicyContent';
import Footer from '../components/Footer';

// プライバシーポリシー
const PrivacyPolicy: VFC = () => (
  <>
    <header>
      <Header1 />
    </header>
    <main>
      <PrivacyPolicyContent />
    </main>
    <footer>
      <Footer />
    </footer>
  </>
);

export default PrivacyPolicy;
src/routes/Terms.tsx
import { VFC } from 'react';
import Header1 from '../components/Header1';
import TermsContent from '../components/TermsContent';
import Footer from '../components/Footer';

// 利用規約
const Terms: VFC = () => (
  <>
    <header>
      <Header1 />
    </header>
    <main>
      <TermsContent />
    </main>
    <footer>
      <Footer />
    </footer>
  </>
);

export default Terms;

ふたつを見比べるとメインコンテンツ以外は同じ構造になっているのがわかると思います。そこで共通部分をレイアウトファイルとして抜き出します。

src/components/layouts/PublicLayout.tsx
import React, { VFC } from 'react';
import Header1 from '../Header1';
import Footer from '../Footer';

type Props = { children: React.ReactNode };

const PublicLayout: VFC<Props> = ({ children }) => (
  <>
    <header>
      <Header1 />
    </header>
    <main>{children}</main>
    <footer>
      <Footer />
    </footer>
  </>
);

export default PublicLayout;

ちなみにpropsで渡しているchildrenは「暗黙のchilren」といって、React 17まではReact.FCを使っている場合は定義しなくてもpropsに入っていました。そのため、childrenを定義せずに使っているサンプルコードが多く存在します。しかしバージョン18からはReact.FCのpropsからchildrenが取り除かれたので、定義を省略するとエラーになります。知らないと気づきにくいので注意喚起しておきます。React.VFCはバージョン18のReact.FCの仕様を先取りした実装です。React.FCはバージョンによって挙動が変わるので、過渡期の2022年現在は上記のようにReact.VFCを使うのが良いでしょう。

レイアウトファイルを利用した新しいプライバシーポリシーと利用規約はこうなります。

src/routes/PrivacyPolicy.tsx
import { VFC } from 'react';
import PrivacyPolicyContent from '../components/PrivacyPolicyContent';
import PublicLayout from '../components/layouts/PublicLayout';

// プライバシーポリシー
const PrivacyPolicy: VFC = () => (
  <PublicLayout>
    <PrivacyPolicyContent />
  </PublicLayout>
);

export default PrivacyPolicy;
src/routes/Terms.tsx
import { VFC } from 'react';
import TermsContent from '../components/TermsContent';
import PublicLayout from '../components/layouts/PublicLayout';

// 利用規約
const Terms: VFC = () => (
  <PublicLayout>
    <TermsContent />
  </PublicLayout>
);

export default Terms;

パブリックなページは元の行数が少ないのであまり恩恵を感じないかもしれませんね。認証が必要なページでは共通化の効果が大きくなるので、実際に見てみましょう。

認証が必要なページの共通処理

最初に全体像をお見せします。

src/components/layouts/AuthenticatedLayout.tsx
import React, { VFC, useEffect, useState } from 'react';
import { Auth } from 'aws-amplify';
import { useAtom } from 'jotai';
import { Navigate } from 'react-router-dom';
import Header2 from '../Header2';
import Footer from '../Footer';
import Spinner from '../Spinner';
import stateCurrentUser from '../../atom/User';
import type { CognitoUser } from '../../atom/User';

type Props = { children: React.ReactNode };
type UserValue = CognitoUser | null;
type UserUpdate = CognitoUser | null;
type UserResult = void;

const AuthenticatedLayout: VFC<Props> = ({ children }) => {
  // サインイン中のユーザー情報
  const [user, setUser] = useAtom<UserValue, UserUpdate, UserResult>(
    stateCurrentUser,
  );

  // 読込中フラグ
  const [isLoading, setIsLoading] = useState<boolean>(true);

  // 要ログインフラグ
  const [loginRequired, setLoginRequired] = useState<boolean>(false);

  // サインイン済みかどうかチェックする
  useEffect(() => {
    // awaitを扱うため、いったん非同期関数を作ってから呼び出している
    const checkSignIn = async () => {
      try {
        // サインイン済みのユーザー情報を取得する
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const currentUser: CognitoUser = await Auth.currentAuthenticatedUser();
        // ユーザー情報をJotaiで管理(これをトリガーにもう一つのEffect Hookが動く)
        setUser(currentUser);
      } catch (e) {
        // サインインしていない場合はログイン画面に遷移させる
        setLoginRequired(true);
      }
    };

    // Promiseを無視して呼び出すことを明示するためvoidを付けている
    void checkSignIn();
  }, [setUser]);

  // サインイン済みチェックが終わったらローディング表示をやめる
  useEffect(() => {
    if (user || loginRequired) setIsLoading(false);
  }, [user, loginRequired]);

  // ローディング表示
  if (isLoading) {
    return (
      <main>
        <Spinner />
      </main>
    );
  }

  // 要ログインの場合はログイン画面に遷移
  if (loginRequired) {
    return <Navigate to="/login" replace />;
  }

  return (
    <>
      <header>
        <Header2 />
      </header>
      <main>{children}</main>
      <footer>
        <Footer />
      </footer>
    </>
  );
};

export default AuthenticatedLayout;

このレイアウトを使用しているマイページとAPIキー確認ページは以下のようになります。

src/routes/MyPage.tsx
import { VFC } from 'react';
import Notice from '../components/Notice';
import Spacer from '../components/Spacer';
import AuthenticatedLayout from '../components/layouts/AuthenticatedLayout';

// マイページ
const MyPage: VFC = () => (
  <AuthenticatedLayout>
    <Notice />
    <Spacer size={50} />
  </AuthenticatedLayout>
);

export default MyPage;
src/routes/ShowAPIKey.tsx
import { VFC } from 'react';
import APIKey from '../components/APIKey';
import Spacer from '../components/Spacer';
import AuthenticatedLayout from '../components/layouts/AuthenticatedLayout';

// APIキー確認ページ
const ShowAPIKey: VFC = () => (
  <AuthenticatedLayout>
    <APIKey />
    <Spacer size={50} />
  </AuthenticatedLayout>
);

export default ShowAPIKey;

さすがにこれくらい複雑な処理だと共通化の恩恵がわかりやすいですね。

簡単に解説しておきます。まずはサインイン済みかどうかチェックしている箇所から。

  useEffect(() => {
    // awaitを扱うため、いったん非同期関数を作ってから呼び出している
    const checkSignIn = async () => {
      try {
        // サインイン済みのユーザー情報を取得する
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        const currentUser: CognitoUser = await Auth.currentAuthenticatedUser();
        // ユーザー情報をJotaiで管理(これをトリガーにもう一つのEffect Hookが動く)
        setUser(currentUser);
      } catch (e) {
        // サインインしていない場合はログイン画面に遷移させる
        setLoginRequired(true);
      }
    };

    // Promiseを無視して呼び出すことを明示するためvoidを付けている
    void checkSignIn();
  }, [setUser]);

ごちゃごちゃしているように見えますが、やっていることはシンプルで、サインイン済みのユーザー情報を取得するAuth.currentAuthenticatedUser()を1回呼び出し、成功したらユーザー情報を保存し、失敗したら(つまりサインインしていなければ)ログイン画面へ遷移させています。ユーザー情報はJotaiで管理していますが、これについては後述します。

もうひとつのEffect Hookはローディング状態の管理をしています。

  useEffect(() => {
    if (user || loginRequired) setIsLoading(false);
  }, [user, loginRequired]);

最初のEffect Hookでサインイン済みのチェックが完了したらuserloginRequiredのどちらかが変化するので、それをトリガーとしてローディングフラグをオフにしています。

初期状態ではローディングフラグがONなので以下の処理分岐に入ります。

  if (isLoading) {
    return (
      <main>
        <Spinner />
      </main>
    );
  }

サインインしていないことが判明した場合はこちらの分岐に入り、ログイン画面へ遷移します。

  if (loginRequired) {
    return <Navigate to="/login" replace />;
  }

サインインしていた場合はこちらの分岐でメインコンテンツが表示されます。

  return (
    <>
      <header>
        <Header2 />
      </header>
      <main>{children}</main>
      <footer>
        <Footer />
      </footer>
    </>
  );

今回はEffect Hookで実装しましたが、せっかくReact 18を使っているので、近いうちにSuspenseで書き換えてみようと思っています。

Amplify公式のサンプルコードではHub.listenで"auth"イベントをフックしていたので、最初は同じようにしようとがんばっていたのですが、途中で私の構成ならHubは不要だと気づいてシンプルになりました。認証前と認証後の表示をひとつのファイルで制御したい場合にはHubが有効だと思いますが、ルーティングを用いて別々のファイルで管理していたら認証のためにHubを使う必要はなさそうです。HubはPub/Subを簡単に実装できる面白そうな機能なので、機会があったらまた使ってみたいと思っています。

Jotaiを用いた状態管理

Auth.currentAuthenticatedUser()で取得したユーザー情報は、ほかのコンポーネントでも利用します。個別のコンポーネントで毎回Auth.currentAuthenticatedUser()を実行しても良いのですが、非同期処理を何度も書くのは面倒なので、状態管理ライブラリを利用することにしました。Reactの状態管理手法はたくさんあり、決定版と呼べる方法はまだ登場していません。利用者が一番多いのはReduxですが、複雑で使いにくいので技術選定からは早々に外しました。シンプルで使いやすそうな状態管理ライブラリをいろいろ探し、最終選考に残ったのがJotaiとRecoilです。時系列的にはRecoilを先に試しましたが、説明の都合上、Jotaiから先に説明します。

https://jotai.org/

Jotaiはdai-shiさんがコントリビューターをされている状態管理ライブラリで、名前の由来は日本語の「状態」です。Reactの情報収集のためReact fanのSlackに参加して、その存在を知りました。

JotaiでCognitoのユーザー情報を管理するには、まずAtomと呼ばれるものを定義します。

src/atom/User.ts
import { atom } from 'jotai';

type Payload = { email: string };
type IdToken = {
  jwtToken: string;
  payload: Payload;
};
type SignInUserSession = { idToken: IdToken };
export type CognitoUser = {
  signInUserSession: SignInUserSession;
  username: string;
  userDataKey: string;
};

const stateCurrentUser = atom<CognitoUser | null>(null);

export default stateCurrentUser;

実際のCognitoユーザー情報はもっと複雑ですが、とりあえず使いたかったもの(email、jwtToken、username)だけ定義しています。

利用する側は次のように記述します。import文は見やすいように余計な部分を削っています。

import React, { VFC, useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import stateCurrentUser from '../../atom/User';
import type { CognitoUser } from '../../atom/User';

type Props = { children: React.ReactNode };

// useAtom用の型エイリアス
type UserValue = CognitoUser | null;
type UserUpdate = CognitoUser | null;
type UserResult = void;

const AuthenticatedLayout: VFC<Props> = ({ children }) => {
  // サインイン中のユーザー情報
  const [user, setUser] = useAtom<UserValue, UserUpdate, UserResult>(
    stateCurrentUser,
  );

{後略}

最初はconst [user, setUser] = useAtom<CognitoUser | null>(stateCurrentUser);と書いていましたが、setUserがエラーになってしまいました。公式ドキュメントをはじめ、いろいろ調べましたが、useAtomで型指定する方法がどこにも見つからず、最終的にJotaiのソースコードを見ながらこの記述方法を思いつきました。

UserValueUserUpdateUserResultという型エイリアスを定義していますが、これらはそれぞれ「userの型」、「setUserの引数の型」、「setUserの戻り値の型」に対応しています。もちろんuseAtom<CognitoUser | null, CognitoUser | null, void>(stateCurrentUser);とベタ書きしても動きますが、半年後の自分自身が読んでも理解できるとは思えません。何を定義しているのかひと目でわかるように型エイリアスは必須でしょう。

ほかのコンポーネントでユーザー情報を参照したい場合は次のように記述します。

  const [user] = useAtom<CognitoUser | null>(stateCurrentUser);

こちらは直感的にわかるので型エイリアスは使っていません。型指定にはハマりましたが、たったこれだけの記述でグローバルな状態を保持できるのはとても楽です。

(4月21日追記)
dai-shiさんによると、atomの型から推論されるのに任せればよいので、useAtomの型は指定しなくてよいそうです。確かにやってみたら型推論が効いていました。そういうわけで最新のコードでは下記のようになっています。シンプルでいいですね。

import React, { VFC, useEffect, useState } from 'react';
import { useAtom } from 'jotai';
import stateCurrentUser from '../../atom/User';
import type { CognitoUser } from '../../atom/User';

type Props = { children: React.ReactNode };

const AuthenticatedLayout: VFC<Props> = ({ children }) => {
  // サインイン中のユーザー情報
  const [user, setUser] = useAtom(stateCurrentUser);

{後略}

Recoilを用いた状態管理

https://recoiljs.org/

RecoilはMeta社謹製の状態管理ライブラリです。ではReact公式の状態管理ライブラリかというとちょっと微妙で、そこは明確に否定されているとか。[1]それでも人気のある状態管理ライブラリであることは確かです。

RecoilもJotaiと同じようにAtomを定義します。

src/atom/User.ts
import { atom } from 'recoil';

type Payload = { email: string };
type IdToken = {
  jwtToken: string;
  payload: Payload;
};
type SignInUserSession = { idToken: IdToken };
export type CognitoUser = {
  signInUserSession: SignInUserSession;
  username: string;
  userDataKey: string;
};

const stateCurrentUser = atom<CognitoUser | null>({
  key: 'CognitoUser',
  default: null,
});

export default stateCurrentUser;

引数の中身がオブジェクトになっただけでJotaiとほとんど同じですね。keyにはグローバルで一意になるようにキーを設定しておきます。defaultはデフォルト値です。

Atomを利用する方は次のように記述します。

import React, { VFC, useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import stateCurrentUser from '../../atom/User';
import type { CognitoUser } from '../../atom/User';

type Props = { children: React.ReactNode };

const AuthenticatedLayout: VFC<Props> = ({ children }) => {
  // サインイン中のユーザー情報
  const [user, setUser] = useRecoilState<CognitoUser | null>(stateCurrentUser);

useAtomuseRecoilStateに変わっただけで、構造的にはJotaiと似ています。型エイリアスがない分、Recoilの方がシンプルですね。

参照だけの場合はこう書きます。

const [user] = useRecoilState<CognitoUser | null>(stateCurrentUser);

Recoilではさらにアプリケーションのルートを<RecoilRoot></RecoilRoot>で囲む必要があります。

src/main.tsxの一部
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
const root = ReactDOM.createRoot(rootElement);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <RecoilRoot>
        <Routes>
          <Route path="/" element={<App />} />
          <Route path="api_key" element={<ShowAPIKey />} />
          <Route path="doc" element={<Doc />} />
          <Route path="login" element={<Login />} />
          <Route path="mypage" element={<MyPage />} />
          <Route path="privacy_policy" element={<PrivacyPolicy />} />
          <Route path="signup" element={<Signup />} />
          <Route path="terms" element={<Terms />} />
          <Route path="thanks" element={<Thanks />} />
          <Route path="tokusyouhou" element={<Tokusyouhou />} />
          <Route path="*" element={<NoMatch />} />
        </Routes>
      </RecoilRoot>
    </BrowserRouter>
  </React.StrictMode>,
);

<RecoilRoot></RecoilRoot>を記述する位置は少し悩みましたがここにしてみました。

さて動かしてみます。 サインインすると、おっとエラーが。

ログを吐かせてみるとTypeError: Cannot freezeで始まるエラーが起きています。Recoilは値がImmutableであることを保証するためにDeep Freezeを行っているそうで[2]、Cognitoのユーザー情報のようにDeep Freeze不可能なオブジェクトを渡すとエラーになります。これを回避するためにはAtomの記述を次のように変更します。

src/atom/User.ts
import { atom } from 'recoil';

type Payload = { email: string };
type IdToken = {
  jwtToken: string;
  payload: Payload;
};
type SignInUserSession = { idToken: IdToken };
export type CognitoUser = {
  signInUserSession: SignInUserSession;
  username: string;
  userDataKey: string;
};

const stateCurrentUser = atom<CognitoUser | null>({
  key: 'CognitoUser',
  default: null,
  dangerouslyAllowMutability: true,
});

export default stateCurrentUser;

dangerouslyAllowMutabilityというオプションを有効にすることでDeep Freezeが回避されます。これでJotaiと同じようにRecoilでも状態管理できるようになりました。

JotaiとRecoilどちらを使うべきか

表面的には似ているJotaiとRecoilですが、設計思想はだいぶ違う気がします。もっと複雑なことをしようとしたら具体的な違いが見えてくるのかもしれませんが、私の使い方では機能的な差はほとんどありません。

個人的には軽くてシンプルな方が良いので、私はJotaiを使うことにします。

まとめ

Cognitoの認証処理をルーティングを含むReactプロジェクトに組み込むための実用的な手段のひとつをご紹介しました。また、状態管理ライブラリのJotaiとRecoilを試してみました。

レイアウトファイルを作って処理を共通化する手法はいくらでも応用が効くので、覚えておくと便利ですよ。

脚注
  1. https://twitter.com/dan_abramov/status/1262143522959998977 ↩︎

  2. https://github.com/facebookexperimental/Recoil/blob/e018c3abfb68f559bf493ba244079fa73f89e79b/src/util/Recoil_deepFreezeValue.js ↩︎

Discussion