Next.js localStorage で永続化したデータを初期化するまでローディングにしたいのメモ
仕様
- 永続化したデータが localStorage に保存されている
- アプリ初期化 (初回ロード) 時に localStorage にアクセスしてデータを state に変換する
- state に基づきコンポーネントの出し分けを行う
環境
- Next.js
13.2.1
- React
18.2.0
- TypeScript
4.9.5
- jotai
2.0.3
localStorage にデータを保存し state として扱うライブラリには jotai を使うことにした
cf. React 👻 jotai を使うと localStorage を使った永続化が簡単だった件について
問題
- アプリ初期化時に localStorage にアクセス可能になるまでにラグがある
- localStorage のデータを state に変換するまでにラグがある (state の更新が非同期のため)
- 永続化 state が初期化されるまで、state が初期値状態のコンポーネントが表示されてしまう
Goal
- 永続化したデータが state に反映されるまでローディングを表示したい
- 初期化中を表す
isReady
のようなフラグを hooks から取得したい (Next.router の isReady のようなイメージ)
- 初期化中を表す
cf.
方針
初回ロード時に下記が完了したら isReady
が true
になってほしい
- localStorage にアクセス可能な常態か判定する
- 永続化したデータが state に反映されているか判定する
- state を使った初期化処理 (非同期処理など) が完了したか
シナリオ
- JTW token を localStorage に保存している
- 初回ロード時に token の有無を確認する
- token がある場合は有効かどうかを確認する
- token が有効な場合はログイン済みと判定する
localStorge を使った token の state
// state/token.atom.ts
import { atomWithStorage } from "jotai/utils";
type HasTokenType = string;
type NoTokenType = null;
export type TokenType = HasTokenType | NoTokenType;
const TOKEN_KEY = 'SOME_KEY_NAME' as const;
const initialState: NoTokenType = null;
export const tokenAtom = atomWithStorage<TokenType>(
TOKEN_KEY,
initialState,
);
state を取得する
// hooks/useToken.ts
import { useAtomValue } from "jotai";
import { tokenAtom } from '@/state/token.atom';
export const useToken = () => {
const token = useAtomValue(tokenAtom);
return token;
}
localStorage から取得して state として反映されるまでの値は初期値
import { useToken } from '@/hooks/useToken';
function App() {
const token = useToken();
useEffect(() => {
console.log({ token });
}, [token]);
return null;
}
token が localStorage にある場合の log は下記の通り
1. { token: null }
2. { token: "token string" }
localStorage にアクセスしてデータを取得し state として反映されるまで tokenAtom
の値は initialState
になる
📝 localStorage にアクセスできない時の state の値は jotai に渡した初期値
localStorage.getItem()
でキーが存在しない時の返り値は null
なので state の初期値が null
だと localStorage にアクセスできてない常態か判別できないと思い state 初期値を undefined
に変更してみたが、jotai がまだ localStorage にアクセスしてない時も常に初期値の undefined
が返されたので jotai だけでは localStorage へのアクセスが完了しているか否かを判定するのは難しそうだった
// state/token.atom.ts
import { atomWithStorage } from "jotai/utils";
type HasTokenType = string;
- type NoTokenType = null;
+ type NoTokenType = undefined;
export type TokenType = HasTokenType | NoTokenType;
export const TOKEN_KEY = 'SOME_KEY_NAME' as const;
- const initialState: NoTokenType = null;
+ const initialState: NoTokenType = undefined;
export const tokenAtom = atomWithStorage<TokenType>(
TOKEN_KEY,
initialState,
);
import { useToken } from '@/hooks/useToken';
const getStorageToken = () => {
if (typeof window === 'undefined') {
// next がサーバーサイドのとき
return false;
}
return localStorage.getItem(TOKEN_KEY);
};
function App() {
const token = useToken();
const storageToken = getStorageToken();
console.log({ storageToken, token });
return null;
}
👇 log
1. { storageToken: "token string", token: undefined }
2. { storageToken: "token string", token: "token string" }
localStorage にアクセスして state に反映するまで jotai の state は初期値を返す
cf.
jotai が localStorage から値を取得して state に反映完了しているかどうかの判定
localStorage から直接取得した値と jotai の state の値が合致しているかで判定できる
※ tokenAtom
の初期値は null
とする
// hooks/useToken.ts
import { useAtomValue } from "jotai";
import { tokenAtom, TokenType, TOKEN_KEY } from '@/state/token.atom';
const checkLocalStorageToken = (token: TokenType): boolean => {
if (typeof window === 'undefined') {
console.log('server', {storageToken: null, token, isReady: false});
return false;
}
const storageToken = localStorage.getItem(TOKEN_KEY);
const isReady = storageToken === null
? storageToken === token
: JSON.parse(storageToken) === token;
console.log({storageToken, token, isReady});
return isReady
};
export const useToken = () => {
const token = useAtomValue(tokenAtom);
const isReady = checkLocalStorageToken(token);
return {token, isReady};
}
👇 console.log
token がある場合
1. "server", {storageToken: null, token: null, isReady: false}
2. {storageToken: "token string", token: null, isReady: false}
3. {storageToken: "token string", token: "token string", isReady: true}
token がない場合 ( localStorage.getItem(TOKEN_KEY)
が null
)
1. "server", {storageToken: null, token: null, isReady: false}
2. {storageToken: null, token: null, isReady: true}
※ そもそも localStorage に token がないので state に反映されるまで待つまでもなく token が無いとして扱って良い
Next.js の場合 !typeof window
でガードしていても上記の書き方での isReady
を使っていると Hydration failed
が発生するので、お作法通り useEffect
内から実行させるほうが良い
// hooks/useToken.ts
import { useAtomValue } from "jotai";
import { tokenAtom, TokenType, TOKEN_KEY } from '@/state/token.atom';
const checkLocalStorageToken = (token: TokenType): boolean => {
const storageToken = localStorage.getItem(TOKEN_KEY);
return = storageToken === null
? storageToken === token
: JSON.parse(storageToken) === token;
};
export const useToken = () => {
const token = useAtomValue(tokenAtom);
const [isReady, setIsReady] = useState<booelan>(false);
useEffect(() => {
setIsReady(checkLocalStorageToken(token));
}, [token]);
return {token, isReady};
}
✅ jotai が永続化されたデータを state に反映するまでを判定できた
import { useToken } from '@/hooks/useToken';
function App() {
const {token, isReady} = useToken();
if (!isReady) { return <div>loading...</div> }
if (!token) { return <div>非ログイン</div> }
return <div>token あり</div>;
}
Roadmap
- localStorage にアクセス可能な常態か判定する
- 永続化したデータが state に反映されているか判定する
- state を使った初期化処理 (非同期処理など) が完了したか
永続化された state (JWT) が有効か判定する
GOAL
- JWT が有効かどうかの判定が完了するまで
isReady
はfalse
にしておきたい - JWTの有効性を確認して最終的な ログイン済み | 非ログイン を判別したい
JWT の有効性を判定する
一旦 state のことは置いておいて、API に JWT token を送信して verify された結果を受け取るようにする
※ JTW を送信して結果を返す関数 verifyToken
は別途定義してあるものとして割愛
import { TokenType } from '@/state/token.atom';
import { useToken } from '@/hooks/useToken';
function App() {
const {token, isReady} = useToken();
const verify = useCallback(async (token: TokenType) => {
if (!token) {
console.log('complete!: 非ログイン');
return;
}
const isVerify = await verifyToken(token); // Return Type: boolean
if (!isVerify) {
// token を削除
console.log('complete!: 非ログイン (token 失効)');
return;
}
console.log('complete!: ログイン済み');
return;
}, []);
useEffect(() => {
if (isReady) {
// jotai の state に反映されてない段階で token 無しと判定しないようにする
return;
}
verify(token);
}, [isReady, token]);
}
上記の verify
関数が return する箇所で全体の isReady
とログイン済みか判定する state を更新すれば良さそう
Auth state
ログイン済みかどうかと、初期化完了のフラグを持つ state を作成した
// state/auth.atom.ts
import { atom } from "jotai";
export const LOGIN = true as const;
export const LOGOUT = false as const;
type AuthType = typeof LOGIN | typeof LOGOUT;
interface IAuthAtom {
isLoggedIn: AuthType;
isReady: boolean;
}
export const initialState: IAuthAtom = {
isLoggedIn: LOGOUT,
isReady: false,
};
export const authAtom = atom<IAuthAtom>(initialState);
// hooks/useAuth.ts
import { useAtomValue, useSetAtom } from "jotai";
import { authAtom, LOGIN, LOGOUT, initialState } from '@/state/auth.atom';
export const useAuth = () => {
return useAtomValue(authAtom);
};
export const useAuthMutators = () => {
const setAuth = useSetAtom(authAtom);
const reset = useCallback(() => {
setAuth(initialState);
}, [setAuth]);
const logout = useCallback(() => {
setAuth({
isLoggedIn: LOGOUT,
isReady: true, // 意図的にログアウトしているので isReady は true
});
}, [setAuth]);
const login = useCallback(() => {
setAuth({
isLoggedIn: LOGIN,
isReady: true,
});
}, [setAuth]);
return {reset, logout, login};
}
✅ 永続化された state (JWT) を使った初期化処理の完了を判定できるようにする
JWT token の verify のチェックを行った後に authAtom
の状態を変更すれば良い
import { TokenType } from '@/state/token.atom';
import { useToken } from '@/hooks/useToken';
+ import { useAuthMutators } from '@/hooks/useAuth';
function App() {
const {token, isReady} = useToken();
+ const {login, logout} = useAuthMutators();
const verify = useCallback(async (token: TokenType) => {
if (!token) {
- console.log('complete!: 非ログイン');
+ logout();
return;
}
const isVerify = await verifyToken(token); // Return Type: boolean
if (!isVerify) {
// token を削除
- console.log('complete!: 非ログイン (token 失効)');
+ logout();
return;
}
- console.log('complete!: ログイン済み');
+ login();
return;
- }, []);
+ }, [login, logout]);
useEffect(() => {
if (isReady) {
// jotai の state に反映されてない段階で token 無しと判定しないようにする
return;
}
verify(token);
}, [isReady, token]);
}
他のコンポーネントでは useAuth()
が返す isReady
と isLoggedIn
をチェックすれば初期化完了とログイン / 非ログインの判定が可能になる
import { useAuth } from '@/hooks/useAuth';
function MyComponent() {
const { isReady, isLoggedIn } = useAuth();
if (!isReady) { return <div>Loading</div> }
if (!isLoggedIn) { return <div>非ログイン</div> }
return <div>ログイン済み!</div>
}
Roadmap
- localStorage にアクセス可能な常態か判定する
- 永続化したデータが state に反映されているか判定する
- state を使った初期化処理 (非同期処理など) が完了したか
JWT token がセットされたときにログイン・ログアウト時に token の削除を行う
token の変更と login / logout の処理がバラバラになっていると面倒なので、特定の関数を呼び出すだけで済むように変更する
方針
ログイン
- ログインフォームの送信
- 成功すると JWT token が発行される (verify 済み)
- JWT token を
tokenAtom
にセット -
authAtom
をログイン状態にする
ログアウト
- ログアウトボタンのクリック
-
tokenAtom
をリセット (localStorage から削除される) -
authAtom
をログアウト状態にする (意図的にログアウトしているのでisReady
はtrue
)
ログインの場合 verify 済みの token が返される筈だが偽造された token が返される可能性があるので、tokenの変更を監視して改めて verify をした上でログイン完了としたほうが良さそう
これは先のコンポーネントの useEffect
が token が変更されたら実行されるので token をセットする為の API を用意すれば良さそう
// hooks/useToken.ts
import { useAtomValue, useSetAtom } from "jotai";
import { RESET } from "jotai/utils";
// 略
export const useToken = () => {
const token = useAtomValue(tokenAtom);
const [isReady, setIsReady] = useState<booelan>(false);
useEffect(() => {
setIsReady(checkLocalStorageToken(token));
}, [token]);
return {token, isReady};
}
export const useTokenMutators = () => {
const setTokenAtom = useSetAtom();
const clearToken = useCallback(() => {
setTokenAtom(RESET);
}, [setTokenAtom]);
const setToken = useCallback((token: string) => {
setTokenAtom(token);
}, [setTokenAtom]);
return {
clearToken,
setToken
};
}
useAuth
に token の変更を紐付けた関数を用意する
// hooks/useAuth.ts
import { TokenType } from '@/state/token.atom';
import { useTokenMutators } from '@/hooks/useToken';
export const useAuthMutators = () => {
const setAuth = useSetAtom(authAtom);
const {setToken, clearToken} = useTokenMutators();
const reset = useCallback(() => {
setAuth(initialState);
clearToken();
}, [setAuth, clearToken]);
const logout = useCallback(() => {
setAuth({
isLoggedIn: LOGOUT,
isReady: true, // 意図的にログアウトしているので isReady は true
});
clearToken();
}, [setAuth, clearToken]);
const login = useCallback(() => {
setAuth({
isLoggedIn: LOGIN,
isReady: true,
});
}, [setAuth]);
// ログインフォームからログインする場合は `loginWithToken` を使う
const loginWithToken = useCallback((token: TokenType) => {
if (!token) {
logout();
return;
}
setToken(token);
// login() を呼び出してもよいが、初期化コンポーネントで token の変更されると verify の処理が実行される
// その結果 login / logout が実行されるので login() を呼び出す必要はない (コンポーネント依存していることになるが)
}, [setToken, logout]);
return {reset, logout, login, loginWithToken};
}
Next.js 永続化された state を初期化するコンポーネント化
永続化している state の初期化が pages/_app.tsx
にあると見通しがあまり良くないので別コンポーネントにしてしまう
// pages/_app.tsx
import type { AppProps } from "next/app";
import { PersistenceObserver } from "@/components/PersistenceObserver";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<PersistenceObserver />
</>
);
}
永続化してる state を監視し初期化するコンポーネント (基本的に初回ロード時と監視対象の変更時に実行してほしい)
// components/PersistenceObserver
import { TokenType } from '@/state/token.atom';
import { useToken } from '@/hooks/useToken';
import { useAuthMutators } from '@/hooks/useAuth';
export const PersistenceObserver: FC = () => {
const {token, isReady} = useToken();
const {login, logout} = useAuthMutators();
const verify = useCallback(async (token: TokenType) => {
if (!token) {
// token が空文字の時もログアウト
logout();
return;
}
const isVerify = await verifyToken(token); // Return Type: boolean
if (!isVerify) {
logout();
return;
}
login();
}, [login, logout]);
useEffect(() => {
if (isReady) {
// jotai の state に反映されてない段階で token 無しと判定しないようにする
return;
}
verify(token);
}, [isReady, token]);
return null;
}
<PersistenceObserver />
の useEffect
は初回ロード時と token
が変更された際に実行される
ここまでで永続化された state の初期化完了するまでローディングを表示し、その後意図しない状態のコンポーネントが一瞬表示されること無く状態に応じてコンポーネントを出し分けることができました
一方で下記が課題感としてある
課題
- 初期化完了の
isReady
を global な state に持たせざるを得なかったがもっと良い方法はないか? - localStorage から state に反映されるまでの
isReady
(hooks内の state) と, 全ての初期化完了のisReady
(global な state) が2つあって若干気持ち悪いので、リファクタリングできないか?
おわり₍ ᐢ. ᴗ .ᐢ ₎