かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React firebase でログインした user を Recoil の atom に保存するとエラーになる

表題のまま。Firebase Authentication を使ってログインした際に返される user を Recoil の atom に保存すると、signout や再度 signin が実行された際にエラーになる

環境
  • react 18.2.0
  • recoil 0.7.7
  • firebase 9.20.0

😱 TypeError: Cannot assign to read only property 'currentUser' of object '#<AuthImpl>'

下記の例では Firebase で Google ログインを監視して useEffect 内で Recoil の userAtom に firebase の onAuthStateChanged から渡される User を保存しています

import { FC, useEffect } from 'react';
import { atom, useRecoilState } from 'recoil';
import { initializeApp } from 'firebase/app';
import { User, getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth';

const auth = getAuth(initializeApp(FIREBASE_CONFIG));
const provider = new GoogleAuthProvider();

type UserType = User | null | undefined;
const userAtom = atom<UserType>({
  key: 'Auth/User',
  default: undefined,
});

const AuthComponent: FC = () => {
  const [user, setUser] = useRecoilState(userAtom);

  const handleSignIn = async () => {
    try {
      await signInWithPopup(auth, provider);
    } catch (error) {
      console.log(error);
    }
  }
  
  const handleSignOut = async () => {
    try {
      await signOut(auth);
    } catch(error) {
      console.log(error);
    }
  }
 
  useEffect(() => {
    const unsubscribed = onAuthStateChanged(auth, (user) => {
      console.log({ user }); // user は User | null
      setUser(user);
    });

    return () => unsubscribed();
  }, []);

  return (
    <div>
      { user && <div>{user.displayName}</div> }
      { user ? <button onClick={handleSignIn}>Sign in</button> : <button onClick={handleSignOut}>Sign out</button> }
    </div>
  );
}

Google ログインが完了した後に Sign out ボタンを押して signOut(auth) でログアウトしようとすると下記のようなエラーになりログアウトができなくなっています
=> TypeError: Cannot assign to read only property 'currentUser' of object '#<AuthImpl>'

原因: Recoil の atom と firebase の auth の相性が悪い

Firebase から返される user は User 型のオブジェクトです。オブジェクトは参照なので Firebase の auth ライブラリはログインしてある user オブジェクトを操作しているようです。一方 Recoil の atom は値がオブジェクトの際は Object.freeze() で外から値を変更できなくする設計になっているようで、user オブジェクトをそのまま渡すと firebase のライブラリが user オブジェクトを変更できなくなりエラーが発生してしまうようでした

これは Recoil が hooks を介さずに状態が変更されるのは望ましくないという設計思想に基づく仕様だと思うので firebase auth のように暗黙的にオブジェクトの状態を変更していくライブラリと相性が良くないということだと思いました

解決方法

  1. Deep copy 又は 必要な値だけを取り出して atom に保存する
  2. React の useContext 又は jotai を使う

1. Deep copy 又は 必要な値だけを取り出して atom に保存する

structuredClone で user を DeepCopy してしまう、又は user から必要な値のみを取り出して保存すれば Recoil に保存される atom が firebase が触る user と切り離されるので問題がなくなる (個人的には必要な情報だけ保存すればいいと思う)

必要なデータだけ取り出して保持するサンプル

// 保持する user atom の interface を作成する
interface IUser {
  displayName: string;
  email: string;
  photoUrl?: string;
}
type UserType = IUser | null | undefined;

const AuthComponent: FC = () => {
  // 略
  useEffect(() => {
    const unsubscribed = onAuthStateChanged(auth, (user) => {
      console.log({ user }); // user は User | null
      if (!user) {
        setUser(null);
        return;
      }
      // 必要なデータだけ取り出して atom に保存
      const { displayName, email, photoUrl } = user;
      setUser({displayName, email, photoUrl});
    });

    return () => unsubscribed();
  }, []);
}

2. React の useContext 又は jotai を使う

React の useContext やそれを利用している jotai は保持する state に対して Object.freeze を行わないので、firebase auth の返す user をそのまま保存しても問題ありませんでした。

所感

Recoil の状態は hooks からしか更新できないという思想は状態を正確に把握しておけるという意味において正しいと感じます。その代わりに思想の異なるライブラリの返すオブジェクトなどとはどうしても相性が悪くなってしまうと思いました。
Recoil を使うなら暗黙的に参照で変更されるようなオブジェクト・配列はそのまま atom に乗せないように気をつける必要がありそうです。
一方で React の Context や jotai は保持している オブジェクトや配列の中身が参照先から変更される可能性があり、それを state の変更として感知できない。ということを念頭においておく必要がありそうです


[参考]