React+ViteでJWTリフレッシュが無限ループする問題を3ステップで解決

目次

JWT無限ループが発生する典型的な3つの原因

React+Viteでの開発中、こんなエラーログを見たことありませんか?

❌ 期限切れトークン → 401エラー → リフレッシュ試行 → また401エラー → 無限ループ 🔄

このJWT認証での無限ループは、多くの開発者が遭遇する典型的なバグです。特にReact+Viteの環境では、高速なHMR(Hot Module Replacement)により複数のAPIコールが同時発生しやすく、問題が顕在化しやすくなります。

原因1: 複数API呼び出しでの同時401エラー

最も一般的な原因です。ダッシュボード表示時など、複数のAPIを並行して呼び出した際、すべてで401エラーが発生し、それぞれでリフレッシュトークン処理が実行されてしまいます。

// ❌ 問題のあるコード例
// 複数のAPIを同時に呼び出すと...
useEffect(() => {
  Promise.all([
    fetchUserProfile(),    // 401エラー → リフレッシュ処理1
    fetchDashboardData(),  // 401エラー → リフレッシュ処理2
    fetchNotifications()   // 401エラー → リフレッシュ処理3
  ])
}, [])

// 結果:3つのリフレッシュ処理が並行実行され無限ループ

原因2: リフレッシュトークン自体の有効期限切れ

リフレッシュトークンが既に無効な状態で、リフレッシュ処理が401エラーを返し続ける場合です。

// ❌ 無効なリフレッシュトークンでの無限ループ
async function refreshToken() {
  const refreshToken = localStorage.getItem('refreshToken')
  
  // リフレッシュトークンが既に無効なので401エラー
  const response = await axios.post('/auth/refresh', { refreshToken })
  // → 再度この関数が呼ばれる → 無限ループ
}

原因3: _retryフラグの設定不備

axios interceptorで無限ループを防ぐ_retryフラグが適切に設定されていない場合です。

// ❌ _retryフラグが機能しないコード
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      // _retryフラグのチェックが抜けている
      await refreshToken()
      return axios.request(error.config) // 無限ループの原因
    }
  }
)

3ステップ解決法:メモ化によるリフレッシュ処理最適化

最も効果的で確実な解決法は、リフレッシュ処理のメモ化です。複数の401エラーが同時に発生しても、実際のリフレッシュ処理は1回だけ実行されます。

ステップ1: メモ化ライブラリの導入と設定

# Viteプロジェクトに必要なパッケージをインストール
npm install axios mem
# または
yarn add axios mem
// src/api/axios.js
import axios from 'axios'
import mem from 'mem'

const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
  timeout: 60000,
  headers: {
    'Content-Type': 'application/json'
  }
})

export default axiosInstance

ステップ2: リフレッシュ関数のメモ化実装

// src/api/auth.js
import axiosInstance from './axios'
import mem from 'mem'

// ✅ メモ化されたリフレッシュ関数
const refreshTokenFn = async () => {
  const refreshToken = localStorage.getItem('refreshToken')
  
  if (!refreshToken) {
    // リフレッシュトークンがない場合はログアウト
    localStorage.clear()
    window.location.href = '/login'
    throw new Error('No refresh token available')
  }

  try {
    // 重要:axiosInstanceではなく、素のaxiosを使用
    const response = await axios.post(
      `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
      { refreshToken }
    )
    
    const { accessToken, refreshToken: newRefreshToken } = response.data
    
    // 新しいトークンを保存
    localStorage.setItem('accessToken', accessToken)
    localStorage.setItem('refreshToken', newRefreshToken)
    
    return accessToken
  } catch (error) {
    // リフレッシュ失敗時はログアウト
    localStorage.clear()
    window.location.href = '/login'
    throw error
  }
}

// メモ化設定(10秒間は同じPromiseを返す)
export const memoizedRefreshToken = mem(refreshTokenFn, {
  maxAge: 10000 // 10秒
})

ステップ3: Axios Interceptorの設定

// src/api/interceptors.js
import axiosInstance from './axios'
import { memoizedRefreshToken } from './auth'

// リクエストインターセプター:アクセストークンを自動付与
axiosInstance.interceptors.request.use(
  config => {
    const accessToken = localStorage.getItem('accessToken')
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`
    }
    return config
  },
  error => Promise.reject(error)
)

// レスポンスインターセプター:401エラー時の自動リフレッシュ
axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config

    // 401エラーかつリフレッシュ処理が未実行の場合
    if (
      error.response?.status === 401 && 
      !originalRequest._retry &&
      originalRequest.url !== '/auth/refresh' // リフレッシュAPI自体は除外
    ) {
      originalRequest._retry = true

      try {
        // ✅ メモ化された関数を呼び出し
        const newAccessToken = await memoizedRefreshToken()
        
        // 元のリクエストに新しいトークンを設定
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
        
        // 元のリクエストを再実行
        return axiosInstance(originalRequest)
      } catch (refreshError) {
        // リフレッシュ失敗時は既にログアウト処理済み
        return Promise.reject(refreshError)
      }
    }

    return Promise.reject(error)
  }
)

export default axiosInstance

Before/After実装コード比較

実際のReact+Viteプロジェクトでの改善例を見てみましょう。

Before: 無限ループが発生するコード

// ❌ 問題のあるコード
// src/hooks/useAuth.js
import { useState, useEffect } from 'react'
import axios from 'axios'

export const useAuth = () => {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  // 毎回新しいaxiosインスタンスを作成(問題1)
  const api = axios.create({
    baseURL: 'http://localhost:3000/api'
  })

  // インターセプターが重複登録される(問題2)
  api.interceptors.response.use(
    response => response,
    async error => {
      if (error.response?.status === 401) {
        // _retryフラグなし(問題3)
        const refreshToken = localStorage.getItem('refreshToken')
        const response = await api.post('/auth/refresh', { refreshToken })
        
        localStorage.setItem('accessToken', response.data.accessToken)
        
        // 元のリクエストを再実行(無限ループの原因)
        return api.request(error.config)
      }
    }
  )

  useEffect(() => {
    // 複数のAPIを同時呼び出し(問題4)
    Promise.all([
      api.get('/user/profile'),
      api.get('/user/dashboard'),
      api.get('/user/notifications')
    ]).then(responses => {
      setUser(responses[0].data)
      setLoading(false)
    })
  }, [])

  return { user, loading }
}

After: 無限ループを解決したコード

// ✅ 改善されたコード
// src/hooks/useAuth.js
import { useState, useEffect } from 'react'
import axiosInstance from '../api/interceptors' // 設定済みのインスタンス

export const useAuth = () => {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const fetchUserData = async () => {
      try {
        setLoading(true)
        setError(null)

        // ✅ 順次実行でリスク軽減
        const [profileRes, dashboardRes, notificationsRes] = await Promise.all([
          axiosInstance.get('/user/profile'),
          axiosInstance.get('/user/dashboard'),  
          axiosInstance.get('/user/notifications')
        ])

        setUser({
          profile: profileRes.data,
          dashboard: dashboardRes.data,
          notifications: notificationsRes.data
        })
      } catch (error) {
        console.error('Failed to fetch user data:', error)
        setError(error.message)
      } finally {
        setLoading(false)
      }
    }

    fetchUserData()
  }, [])

  return { user, loading, error }
}

アプリケーションのエントリーポイント設定

// src/main.jsx(Viteのエントリーポイント)
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './api/interceptors' // ✅ インターセプターを最初に読み込み

ReactDOM.createRoot(document.getElementById('root')).render(
  
    
  
)

その他の対策法と予防策

対策法1: フラグベースアプローチ

メモ化が使えない場合の代替手段として、LocalStorageでフラグ管理する方法もあります。

// src/api/flagBasedRefresh.js
let isRefreshing = false
let failedQueue = []

const processQueue = (error, token = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else {
      resolve(token)
    }
  })
  
  failedQueue = []
}

axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // ✅ 既にリフレッシュ中の場合はキューに追加
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        }).then(token => {
          originalRequest.headers.Authorization = `Bearer ${token}`
          return axiosInstance(originalRequest)
        }).catch(err => {
          return Promise.reject(err)
        })
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        const refreshToken = localStorage.getItem('refreshToken')
        const response = await axios.post('/auth/refresh', { refreshToken })
        const newAccessToken = response.data.accessToken
        
        localStorage.setItem('accessToken', newAccessToken)
        
        // ✅ キューに溜まっているリクエストを処理
        processQueue(null, newAccessToken)
        
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
        return axiosInstance(originalRequest)
      } catch (refreshError) {
        processQueue(refreshError, null)
        localStorage.clear()
        window.location.href = '/login'
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  }
)

予防策1: トークン有効期限の事前チェック

// src/utils/tokenUtils.js
export const isTokenExpired = (token) => {
  if (!token) return true
  
  try {
    const payload = JSON.parse(atob(token.split('.')[1]))
    const currentTime = Date.now() / 1000
    
    // 有効期限の30秒前に期限切れと判定
    return payload.exp < (currentTime + 30)
  } catch {
    return true
  }
}

// リクエスト前にトークンチェック
axiosInstance.interceptors.request.use(
  async config => {
    const accessToken = localStorage.getItem('accessToken')
    
    if (isTokenExpired(accessToken)) {
      // 事前にリフレッシュ実行
      const newToken = await memoizedRefreshToken()
      config.headers.Authorization = `Bearer ${newToken}`
    } else if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`
    }
    
    return config
  },
  error => Promise.reject(error)
)

予防策2: 開発時のデバッグツール

// src/utils/debugInterceptor.js
if (import.meta.env.DEV) {
  let requestCount = 0
  
  axiosInstance.interceptors.request.use(config => {
    requestCount++
    console.log(`🚀 Request #${requestCount}: ${config.method?.toUpperCase()} ${config.url}`)
    
    if (requestCount > 10) {
      console.warn('⚠️  多数のリクエストが検出されました。無限ループの可能性があります。')
    }
    
    return config
  })

  axiosInstance.interceptors.response.use(
    response => {
      console.log(`✅ Response: ${response.config.url} - ${response.status}`)
      return response
    },
    error => {
      console.log(`❌ Error: ${error.config?.url} - ${error.response?.status}`)
      return Promise.reject(error)
    }
  )
}
あわせて読みたい
React+ViteとNext.jsの違いを理解しよう!SPAとSSRの基本と選び方 Reactでアプリを作りたいけど、React+ViteとNext.jsのどちらを選べばいいかわからない。そんな初心者エンジニアのために、それぞれの特徴と違いを図解と具体例でわかり...

よくあるエラーパターンと即解決法

エラー1: “Maximum call stack size exceeded”

// ❌ 原因:axios instanceの循環参照
const api = axios.create()
api.interceptors.response.use(
  res => res,
  async error => {
    if (error.response?.status === 401) {
      await api.post('/refresh') // ❌ 同じインスタンスを使用
    }
  }
)

// ✅ 解決法:素のaxiosを使用
api.interceptors.response.use(
  res => res,
  async error => {
    if (error.response?.status === 401) {
      await axios.post('/refresh') // ✅ 素のaxiosを使用
    }
  }
)

エラー2: “Network Error” の連続発生

// ✅ 解決法:リフレッシュ回数の制限
let refreshAttempts = 0
const MAX_REFRESH_ATTEMPTS = 3

axiosInstance.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401 && refreshAttempts < MAX_REFRESH_ATTEMPTS) {
      refreshAttempts++
      
      try {
        await memoizedRefreshToken()
        return axiosInstance(error.config)
      } catch (refreshError) {
        if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
          refreshAttempts = 0
          // 強制ログアウト
          localStorage.clear()
          window.location.href = '/login'
        }
        return Promise.reject(refreshError)
      }
    }
    
    refreshAttempts = 0 // 成功時はリセット
    return Promise.reject(error)
  }
)

エラー3: Vite環境変数の読み込みエラー

// ❌ 間違った環境変数の使用
const API_URL = process.env.REACT_APP_API_URL // ❌ Viteでは動作しない

// ✅ 正しいViteの環境変数
const API_URL = import.meta.env.VITE_API_URL

// .env.local ファイルの設定例
// VITE_API_URL=http://localhost:3000/api
// VITE_APP_NAME=My React App

まとめ:JWT無限ループの確実な対策法

React+ViteでのJWT無限ループ問題は、以下の3ステップで確実に解決できます:

  • メモ化の導入: memライブラリでリフレッシュ処理を一意化
  • 適切なインターセプター設定: _retryフラグと循環参照の回避
  • 予防的なトークン管理: 有効期限の事前チェックとデバッグツール

特にメモ化によるアプローチは、複数API呼び出しが頻発するモダンなSPAにおいて最も効果的な解決策です。実装も比較的シンプルで、既存のコードへの影響を最小限に抑えられます。

この記事の実装パターンをコピペして使用すれば、JWT認証の無限ループ問題は確実に解決できます。開発時のデバッグツールも活用して、今後の問題を未然に防ぎましょう。

React+ViteとNext.jsの技術選択で迷っている方は、上記の記事も参考にして、プロジェクトに最適なフレームワークを選択してください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次