React・本番で気をつけること

ログインと、入れないページ

ログイン状態をどこに持つか、入れないページをどう作るか。react-routerでの保護ルート、ログイン後に元のページへ戻す書き方、トークンの置き場所、そして「画面で隠すのは認可ではない」という線引きを扱います。

ログインのあるアプリを触ることになったとき、フロント側で必要になるのは大きく3つです。

  1. ログインしているかどうかを、アプリ全体で知る
  2. ログインしていない人を、ページに入れない
  3. 通信にトークンを付ける

サーバー側の作り方はここでは扱いません。フロント側の受け持ちだけを確実にします。

ログイン状態を持つ

状態管理ライブラリの回で入れたZustandに、ログイン用のストアを足します。

import { create } from 'zustand'

type User = { name: string }

type AuthStore = {
  user: User | null
  token: string | null
  isLoading: boolean
  login: (name: string) => Promise<void>
  logout: () => void
}

export const useAuthStore = create<AuthStore>()((set) => ({
  user: null,
  token: null,
  isLoading: false,
  login: async (name) => {
    set({ isLoading: true })
    await new Promise((resolve) => setTimeout(resolve, 300))   // サーバーに問い合わせるつもり
    set({ user: { name }, token: 'dummy-token', isLoading: false })
  },
  logout: () => set({ user: null, token: null }),
}))

usernullかどうかが、そのままログインの有無になります。isLoggedInのようなbooleanを別に持つと、2つの値がずれる余地ができます。stateの回で扱った「1つの事実は1か所に」です。

入れないページを作る

react-routerでは、ルートを包む部品を作ります。

import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore.ts'

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const user = useAuthStore((state) => state.user)
  const location = useLocation()

  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />
  }

  return children
}

使うときは、守りたいルートを包みます。

<Route
  path="/mypage"
  element={
    <RequireAuth>
      <MyPage />
    </RequireAuth>
  }
/>

未ログインで/mypageを開くと、/loginに移ります(実際に確かめると、URLがhttp://localhost:5173/loginになり、ログイン画面が出ます)。

読みどころは3つです。

書いたもの意味
<Navigate>を返す描くのではなく移動そのものを返すuseEffectで移動させるより早く、ちらつかない
replace履歴を置きかえる。付けないと「戻る」でまた/mypageに戻ってループする
state={{ from: location }}どこへ行こうとしたかを持っていく(次で使います)

ログイン後に、元のページへ戻す

/mypageを見たかった人を、ログイン後にトップへ放り出すと不親切です。持ってきたfromを使います。

const navigate = useNavigate()
const location = useLocation()
const login = useAuthStore((state) => state.login)

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault()
  await login(name)

  const from = location.state?.from?.pathname ?? '/'
  navigate(from, { replace: true })
}

「入ろうとした→はじかれた→ログインした→そこへ戻る」——この一往復が自然に繋がります。実務でここが抜けていると、地味に毎回いらつく部分です。

トークンの置き場所

サーバーから受け取ったトークンをどこに置くか。フロントだけでは決められない話ですが、選択肢は知っておきます。

置き場所利点弱点
localStorage簡単。リロードしても残るXSSが起きると読み出せる前回
メモリ(stateだけ)盗まれにくいリロードで消える
httpOnlyのCookieJavaScriptから読めない=XSSでも盗まれにくいサーバー側の実装が要る。CSRF対策も要る

サーバーを自分たちで作れるならhttpOnlyのCookieが基本です。この場合フロントはトークンを一切触らず、fetchcredentials: 'include'を付けるだけになります。

localStorageを使う場合は、トークンを付ける処理を1か所にまとめます

export async function apiFetch(path: string, options: RequestInit = {}) {
  const token = useAuthStore.getState().token
  const response = await fetch(path, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  })

  if (response.status === 401) {
    useAuthStore.getState().logout()   // 期限切れならログアウト
  }
  return response
}

getState()で読めるので、この関数はコンポーネントでなくても書けます。ストアをReactの外に置く利点がここで効きます。

Reactページを分ける(React Router)ルーティングの回。<Navigate>useLocationはここで出てきたものです

公式ドキュメントも見てみる

reactrouter.com外部サイトNavigating | React Routerreact-router公式の画面移動まわり。<Navigate>useNavigateの使い分け、replaceの意味がまとまっています(英語)

このレッスンのまとめ

  1. ログイン状態はusernullかどうかで表す。booleanを別に持たない
  2. Contextでも足りる。ストアにするとReactの外(通信処理)から読める
  3. 入れないページはルートを包む部品で作る。<Navigate>返す
  4. replaceを付けないと「戻る」でループする
  5. state={{ from: location }}で、ログイン後に元のページへ戻せる
  6. 「未ログイン」と「確認中」を分けないと、中身が一瞬見える
  7. トークンはhttpOnlyのCookieが基本。localStorageならXSSの影響を受ける
  8. 画面で隠すのは認可ではない。確認するのは必ずサーバー

次は、多言語対応です。文言をコードの外に出します。

やってみよう:マイページを作って、守る

ことりずかんに、ログインしないと入れないページを足します。

  1. src/stores/authStore.tsを作り、userloginlogoutを持たせる
  2. RequireAuthを作り、userが無ければ<Navigate to="/login" replace />を返す
  3. /mypage(お気に入りだけを並べるページ)をRequireAuthで包む
  4. /loginに名前だけのフォームを作り、送信したらlogin(name)を呼ぶ
  5. 未ログインで/mypageを開いて/loginに飛ぶことを確認する
  6. ログイン後に/mypageへ戻ってくるようにする(location.state.from
<Route path="/mypage" element={<RequireAuth><MyPage /></RequireAuth>} />

6番まで通すと、実務のログインまわりで書くことはひととおり体験できます。5番のあとブラウザの「戻る」を押してみるのも忘れずに——replaceを外していると、そこでループします。

よくある質問

ログイン状態はどこに持つのがよいですか?
アプリ全体で見るのでContextか状態管理ライブラリです。ログインの有無はめったに変わらないのでContextでも問題は起きにくく、通信処理からも読みたいならZustandのようにReactの外から読めるものが便利です。
トークンはlocalStorageに置いてよいですか?
手軽ですが、XSSが起きたときに読み出せてしまいます。より安全なのはサーバーがhttpOnlyのCookieで持つ方式で、この場合フロントはトークンを触りません。どちらにするかはサーバー側と一緒に決める話です。
ボタンを隠せば権限の制御になりますか?
なりません。画面の出し分けは使いやすさのためのもので、APIを直接呼ばれれば通ってしまいます。権限の確認は必ずサーバー側で行います。
ログイン前に一瞬ページが見えてしまいます。
ログイン状態を確認し終える前に描いているのが原因です。確認中という状態を用意して、その間はリダイレクトも本体の表示もせず、読みこみ中を出すようにします。