You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Router Dom v6无法维持Supabase Context:刷新跳转登录页问题

问题描述

我基于Vite+React+Supabase搭建了应用骨架,配置了路由并创建Auth Context用于判断用户登录状态。该Context会检查localStorage中的tokens,若存在则应跳转至'dashboard'路径。登录后功能运行正常,但页面刷新时,即使localStorage中仍保存着tokens,仍会被重定向到登录页。多次调整路由系统仍未定位到问题,希望实现:页面刷新时若localStorage中存在token,自动跳转至'dashboard'而非登录页。

App.tsx

import {
  createBrowserRouter,
  Navigate,
  Outlet,
  RouterProvider,
  useLocation,
} from "react-router-dom";

import { AuthContext } from "./context/AuthContext";
import { AuthProvider } from "./context/AuthContext";

import Login from "./pages/login/login";
import { useContext } from "react";
import Dashboard from "./pages/Dashboard/dashboard";
import { Sidebar } from "./components/custom/sidebar";

const PrivateRoutes = () => {
  const location = useLocation();
  const { tokens } = useContext(AuthContext);

  return tokens.access_token !== null ? (
    <div className="bg-background h-screen">
      <div className="grid lg:grid-cols-5 h-screen">
        <Sidebar className="hidden lg:flex lg:flex-col h-screen" />
        <div className="col-span-3 lg:col-span-4 lg:border-l">
          <div className="h-full px-4 py-6 lg:px-8">
            <Outlet />
          </div>
        </div>
      </div>
    </div>
  ) : (
    <Navigate to="/login" replace state={{ from: location }} />
  );
};

const router = createBrowserRouter([
  {
    path: "/",
    element: <PrivateRoutes />,
    children: [
      {
        path: "dashboard",
        element: <Dashboard />,
      },
    ],
  },
  {
    path: "/login",
    element: <Login />,
  },
]);

function App() {
  return (
    <>
      <AuthProvider>
        <RouterProvider router={router} />
      </AuthProvider>
    </>
  );
}

export default App;

Login.tsx

import { useContext } from "react";
import { AuthContext } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
import { useSignal } from "@preact/signals-react";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ReloadIcon } from "@radix-ui/react-icons";
import { Toaster, toast } from "sonner";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { signIn, supabase } from "@/utils/supabase";

import { useNavigate } from "react-router-dom";

const formSchema = z.object({
  email: z.string().email({
    message: "Email address is not valid",
  }),
  password: z.string().min(8, {
    message: "Password must be at least 8 characters.",
  }),
});

export default function Login() {
  const { setTokens } = useContext(AuthContext);

  const isLoading = useSignal(false);
  const navigate = useNavigate();

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
  });

  const onSubmit = async (data: z.infer<typeof formSchema>) => {
    isLoading.value = true;

    const res = await signIn(data);
    if (res.error) toast.error("Errore, credenziali errate");
    else {
      setTokens(res.data.session.access_token, res.data.session.refresh_token);

      localStorage.setItem("access_token", res.data.session.access_token);
      localStorage.setItem("refresh_token", res.data.session.access_token);

      supabase.auth.setSession({ access_token: res.data.session.access_token, refresh_token: res.data.session.refresh_token });
      navigate('/dashboard');
    }

    isLoading.value = false;
  };

  return (
    <>
      <div className="container h-screen relative hidden flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
        <Button asChild className={cn("absolute right-4 top-4 md:right-8 md:top-8")}>
          <a href="auth">Register</a>
        </Button>
        <div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
          <div className="absolute inset-0 bg-zinc-900" />
          <div className="relative z-20 flex items-center text-lg font-medium">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="mr-2 h-6 w-6"
            >
              <path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
            </svg>
            Acme Inc
          </div>
          <div className="relative z-20 mt-auto">
            <blockquote className="space-y-2">
              <p className="text-lg">
                &ldquo;This library has saved me countless hours of work and
                helped me deliver stunning designs to my clients faster than
                ever before.&rdquo;
              </p>
              <footer className="text-sm">Sofia Davis</footer>
            </blockquote>
          </div>
        </div>
        <div className="lg:p-8">
          <div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[450px]">
            <div className="flex flex-col space-y-2">
              <h1 className="text-2xl font-semibold tracking-tight mb-2 text-center">
                Login in your account
              </h1>
            </div>
            <Form {...form}>
              <form
                onSubmit={form.handleSubmit(onSubmit)}
                className="grid gap-y-3"
              >
                <FormField
                  control={form.control}
                  name="email"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Email</FormLabel>
                      <FormControl>
                        <Input placeholder="Enter your email" {...field} />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
                <FormField
                  control={form.control}
                  name="password"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Password</FormLabel>
                      <FormControl>
                        <Input
                          placeholder="Enter your password"
                          type="password"
                          {...field}
                        />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
                <div className="pt-6 flex w-full">
                  <Button
                    className="w-full"
                    type="submit"
                    disabled={isLoading.value}
                  >
                    {isLoading.value === false ? (
                      <>
                        <span>Login</span>
                      </>
                    ) : (
                      <>
                        <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
                        please wait
                      </>
                    )}
                  </Button>
                </div>
              </form>
            </Form>
          </div>
        </div>
      </div>
      <Toaster />
    </>
  );
}

AuthContext.tsx

import { createContext, useState, useEffect, ReactNode } from 'react';

interface Tokens {
  access_token: string | null;
  refresh_token: string | null;
}

interface AuthContextProps {
  tokens: Tokens;
  setTokens: (accessToken: string | null, refreshToken: string | null) => void;
}

const initialAuthContext: AuthContextProps = {
  tokens: {
    access_token: null,
    refresh_token: null,
  },
  setTokens: () => {},
};

const AuthContext = createContext<AuthContextProps>(initialAuthContext);

interface AuthProviderProps {
  children: ReactNode;
}

const AuthProvider = ({ children }: AuthProviderProps) => {
  const [tokens, setTokens] = useState<Tokens>({
    access_token: null,
    refresh_token: null,
  });

  const setToken = (accessToken: string | null, refreshToken: string | null) => {
    setTokens({ access_token: accessToken, refresh_token: refreshToken });
  };

  // Check for tokens in local storage on component mount
  useEffect(() => {
    const storedAccessToken = localStorage.getItem('access_token');
    const storedRefreshToken = localStorage.getItem('refresh_token');

    if (storedAccessToken && storedRefreshToken) {
      setTokens({ access_token: storedAccessToken, refresh_token: storedRefreshToken });
    }

    console.log(tokens);
  }, []);

  return (
    <AuthContext.Provider value={{ tokens, setTokens: setToken }}>
      {children}
    </AuthContext.Provider>
  );
};

export { AuthContext, AuthProvider };
解决方案

问题根源

  1. 异步状态更新导致路由判断过早:页面刷新时,RouterProvider先于AuthProvider的useEffect执行完成,此时tokens还是初始的null值,PrivateRoutes直接触发跳转到登录页,等useEffect读取localStorage更新状态时,跳转已经完成。
  2. Login组件的refresh_token存储错误:代码中把access_token重复存到了refresh_token的key下,导致localStorage里的refresh_token值错误,AuthContext读取时会因为refresh_token无效不更新状态。

修复步骤

1. 修正Login.tsx中的token存储错误

找到onSubmit函数里的这行代码:

localStorage.setItem("refresh_token", res.data.session.access_token);

改为:

localStorage.setItem("refresh_token", res.data.session.refresh_token);

2. 在AuthContext中添加加载状态,避免初始错误跳转

修改AuthContext.tsx,添加isLoading状态,在读取localStorage完成前不进行路由判断:

import { createContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '@/utils/supabase';

interface Tokens {
  access_token: string | null;
  refresh_token: string | null;
}

interface AuthContextProps {
  tokens: Tokens;
  setTokens: (accessToken: string | null, refreshToken: string | null) => void;
  isLoading: boolean;
}

const initialAuthContext: AuthContextProps = {
  tokens: {
    access_token: null,
    refresh_token: null,
  },
  setTokens: () => {},
  isLoading: true,
};

const AuthContext = createContext<AuthContextProps>(initialAuthContext);

interface AuthProviderProps {
  children: ReactNode;
}

const AuthProvider = ({ children }: AuthProviderProps) => {
  const [tokens, setTokens] = useState<Tokens>({
    access_token: null,
    refresh_token: null,
  });
  const [isLoading, setIsLoading] = useState(true);

  const setToken = (accessToken: string | null, refreshToken: string | null) => {
    setTokens({ access_token: accessToken, refresh_token: refreshToken });
  };

  useEffect(() => {
    const storedAccessToken = localStorage.getItem('access_token');
    const storedRefreshToken = localStorage.getItem('refresh_token');

    if (storedAccessToken && storedRefreshToken) {
      setTokens({ access_token: storedAccessToken, refresh_token: storedRefreshToken });
      // 同步Supabase会话状态
      supabase.auth.setSession({
        access_token: storedAccessToken,
        refresh_token: storedRefreshToken
      });
    }
    setIsLoading(false);
  }, []);

  return (
    <AuthContext.Provider value={{ tokens, setTokens: setToken, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
};

export { AuthContext, AuthProvider };

3. 修改PrivateRoutes组件,处理加载状态

在PrivateRoutes中,判断isLoading状态,加载时显示加载提示,避免直接跳转:

const PrivateRoutes = () => {
  const location = useLocation();
  const { tokens, isLoading } = useContext(AuthContext);

  if (isLoading) {
    return <div>加载中...</div>; // 可替换为项目自定义加载组件
  }

  return tokens.access_token !== null ? (
    <div className="bg-background h-screen">
      <div className="grid lg:grid-cols-5 h-screen">
        <Sidebar className="hidden lg:flex lg:flex-col h-screen" />
        <div className="col-span-3 lg:col-span-4 lg:border-l">
          <div className="h-full px-4 py-6 lg:px-8">
            <Outlet />
          </div>
        </div>
      </div>
    </div>
  ) : (
    <Navigate to="/login" replace state={{ from: location }} />
  );
};

4. 优化路由配置,根路径默认跳转至dashboard

修改路由配置,给根路径添加index跳转,避免用户访问根路径时无内容:

const router = createBrowserRouter([
  {
    path: "/",
    element: <PrivateRoutes />,
    children: [
      {
        index: true,
        element: <Navigate to="/dashboard" replace />
      },
      {
        path: "dashboard",
        element: <Dashboard />,
      },
    ],
  },
  {
    path: "/login",
    element: <Login />,
  },
]);

额外说明

  • 建议使用Supabase官方的authStateChange监听状态变化,而非手动管理localStorage,能更好同步Supabase的auth状态,避免手动管理token带来的不一致问题。
  • 加载状态的UI可替换为项目中的自定义加载组件,提升用户体验。

内容的提问来源于stack exchange,提问作者Filippo Brigati

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 06:27:02