Open12

Next.js localStorage で永続化したデータを初期化するまでローディングにしたいのメモ

KiKiKi-KiKiKiKiKi-KiKi

仕様

  1. 永続化したデータが localStorage に保存されている
  2. アプリ初期化 (初回ロード) 時に localStorage にアクセスしてデータを state に変換する
  3. 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 を使った永続化が簡単だった件について

問題

  1. アプリ初期化時に localStorage にアクセス可能になるまでにラグがある
  2. localStorage のデータを state に変換するまでにラグがある (state の更新が非同期のため)
  3. 永続化 state が初期化されるまで、state が初期値状態のコンポーネントが表示されてしまう

Goal

  • 永続化したデータが state に反映されるまでローディングを表示したい
    • 初期化中を表す isReady のようなフラグを hooks から取得したい (Next.router の isReady のようなイメージ)

cf.

KiKiKi-KiKiKiKiKi-KiKi

方針

初回ロード時に下記が完了したら isReadytrue になってほしい

  1. localStorage にアクセス可能な常態か判定する
  2. 永続化したデータが state に反映されているか判定する
  3. state を使った初期化処理 (非同期処理など) が完了したか

シナリオ

  1. JTW token を localStorage に保存している
  2. 初回ロード時に token の有無を確認する
  3. token がある場合は有効かどうかを確認する
  4. token が有効な場合はログイン済みと判定する
KiKiKi-KiKiKiKiKi-KiKi

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 になる

KiKiKi-KiKiKiKiKi-KiKi

📝 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.

KiKiKi-KiKiKiKiKi-KiKi

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 が無いとして扱って良い

KiKiKi-KiKiKiKiKi-KiKi

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 を使った初期化処理 (非同期処理など) が完了したか
KiKiKi-KiKiKiKiKi-KiKi

永続化された state (JWT) が有効か判定する

GOAL

  • JWT が有効かどうかの判定が完了するまで isReadyfalse にしておきたい
  • 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 を更新すれば良さそう

cf. https://chaika.hatenablog.com/entry/2023/04/11/083000

KiKiKi-KiKiKiKiKi-KiKi

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};
}
KiKiKi-KiKiKiKiKi-KiKi

✅ 永続化された 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() が返す isReadyisLoggedIn をチェックすれば初期化完了とログイン / 非ログインの判定が可能になる

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 を使った初期化処理 (非同期処理など) が完了したか
KiKiKi-KiKiKiKiKi-KiKi

JWT token がセットされたときにログイン・ログアウト時に token の削除を行う

token の変更と login / logout の処理がバラバラになっていると面倒なので、特定の関数を呼び出すだけで済むように変更する

方針

ログイン

  1. ログインフォームの送信
  2. 成功すると JWT token が発行される (verify 済み)
  3. JWT token を tokenAtom にセット
  4. authAtom をログイン状態にする

ログアウト

  1. ログアウトボタンのクリック
  2. tokenAtom をリセット (localStorage から削除される)
  3. authAtom をログアウト状態にする (意図的にログアウトしているので isReadytrue )

ログインの場合 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};
}
KiKiKi-KiKiKiKiKi-KiKi

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 が変更された際に実行される

KiKiKi-KiKiKiKiKi-KiKi

ここまでで永続化された state の初期化完了するまでローディングを表示し、その後意図しない状態のコンポーネントが一瞬表示されること無く状態に応じてコンポーネントを出し分けることができました
一方で下記が課題感としてある

課題

  • 初期化完了の isReady を global な state に持たせざるを得なかったがもっと良い方法はないか?
  • localStorage から state に反映されるまでの isReady (hooks内の state) と, 全ての初期化完了の isReady (global な state) が2つあって若干気持ち悪いので、リファクタリングできないか?

おわり₍ ᐢ. ᴗ .ᐢ ₎