ログインのあるアプリを触ることになったとき、フロント側で必要になるのは大きく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 }),
}))userがnullかどうかが、そのままログインの有無になります。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のCookie | JavaScriptから読めない=XSSでも盗まれにくい | サーバー側の実装が要る。CSRF対策も要る |
サーバーを自分たちで作れるならhttpOnlyのCookieが基本です。この場合フロントはトークンを一切触らず、fetchにcredentials: '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の外に置く利点がここで効きます。
<Navigate>とuseLocationはここで出てきたものです公式ドキュメントも見てみる
reactrouter.com外部サイトNavigating | React Routerreact-router公式の画面移動まわり。<Navigate>とuseNavigateの使い分け、replaceの意味がまとまっています(英語)このレッスンのまとめ
- ログイン状態は
userがnullかどうかで表す。booleanを別に持たない - Contextでも足りる。ストアにするとReactの外(通信処理)から読める
- 入れないページはルートを包む部品で作る。
<Navigate>を返す replaceを付けないと「戻る」でループするstate={{ from: location }}で、ログイン後に元のページへ戻せる- 「未ログイン」と「確認中」を分けないと、中身が一瞬見える
- トークンはhttpOnlyのCookieが基本。localStorageならXSSの影響を受ける
- 画面で隠すのは認可ではない。確認するのは必ずサーバー
次は、多言語対応です。文言をコードの外に出します。