Search code examples
reactjsauthenticationreact-routerreact-router-domsupabase

React Router Dom v6 does not mantain Context (Supabase)


I created the skeleton of my app, with vite + react and supabase.

I set all the routes and created a context to understand if a user is logged in or not.

the context checks the localstorage if the tokens are there and if it finds them it should report to the path 'dashboard'.

once logged in everything works correctly, the problem is that when I refresh I am brought back to the login page even though the tokens are still saved in the localstorage.

I'm stuck and I can't solve it... could someone help me?

I tried to change the entire routing system several times but I don't understand where the error is.

I would like if the token is present in the localstorage you are redirected to the 'dashboard' path and not to the login after reloading the page

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 {
      //const { from } = location.state || { from: { pathname: "/" } };

      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 };

Solution

  • Issue

    The AuthProvider component uses the same initial state value as an unauthenticated user, so the redirection is immediately effected upon page reload. In other words, the AuthProvider provides a null token value on the initial render and PrivatesRoutes renders the redirect to the login page.

    Solution

    Start from an indeterminant state, e.g. undefined, and conditionally wait until the authentication status is confirmed/set in the auth context.

    interface Tokens {
      access_token: string | undefined| null;
      refresh_token: string | undefined | null;
    }
    
    const AuthProvider = ({ children }: AuthProviderProps) => {
      const [tokens, setTokens] = useState<Tokens>({
        access_token: undefined,
        refresh_token: undefined,
      });
    
      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');
    
        setTokens({
          access_token: storedAccessToken ?? null,
          refresh_token: storedRefreshToken ?? null
        });
      }, []);
    
      return (
        <AuthContext.Provider value={{ tokens, setTokens: setToken }}>
          {children}
        </AuthContext.Provider>
      );
    };
    

    Update PrivateRoutes to conditionally render null or some loading indicator while the auth status is confirmed/set.

    const PrivateRoutes = () => {
      const location = useLocation();
      const { tokens } = useContext(AuthContext);
    
      if (tokens.access_token === undefined) {
        return null; // or loading indicator/spinner/etc
      }
    
      // tokens.access_token is either null or string at this point
      return tokens.access_token ? (
        <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 }} />
      );
    };
    

    An alternative is to use a lazy initializer function to set the initial state value in lieu of the useEffect hook. This will provide the correct initial state for the initial render cycle for the PrivateRoutes component.

    const AuthProvider = ({ children }: AuthProviderProps) => {
      const [tokens, setTokens] = useState<Tokens>(() => {
        const storedAccessToken = localStorage.getItem('access_token');
        const storedRefreshToken = localStorage.getItem('refresh_token');
    
        return {
          access_token: storedAccessToken ?? null,
          refresh_token: storedRefreshToken ?? null
        };
      });
    
      const setToken = (accessToken: string | null, refreshToken: string | null) => {
        setTokens({ access_token: accessToken, refresh_token: refreshToken });
      };
    
      return (
        <AuthContext.Provider value={{ tokens, setTokens: setToken }}>
          {children}
        </AuthContext.Provider>
      );
    };