表題のまま。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 に保存する
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 の変更として感知できない。ということを念頭においておく必要がありそうです
[参考]
- Uncaught TypeError: Cannot freeze · Issue #406 · facebookexperimental/Recoil · GitHub
- signOut fails due to a readonly property in Auth · Issue #5722 · firebase/firebase-js-sdk · GitHub
- [firebase] Recoil 3.0 cannot work with firebase · Issue #1053 · facebookexperimental/Recoil · GitHub
- structuredClone() - Web API | MDN