T
TanStack2mo ago
fascinating-indigo

How to update router context globally within `beforeLoad`?

I have a layout route "/admin/_authenticated". I want to check context's isAuthenticated property in beforeLoad method of this route and update the context in such a way that the updated context is immediately reflected everywhere, as if I'm updating a global variable. Global variable technique works fine, but I think it's not a clean way of doing that. My use case: Implementing cookie-based auth. Backend is separate (Express). The idea is to check whether session is active on initial load, and if so, to update isAuthenticated key of context, accordingly; otherwise, to redirect to login route. The problem is that the context keeps being stale on beforeLoad calls executed when redirecting user among sibling routes. Another approach would be to use a custom React Context hook, but there is no convenient way to "inject" the hook into the router like it's done in Tanstack Router (example). Thank you for building such a great toolchain.
26 Replies
harsh-harlequin
harsh-harlequin2mo ago
you can use RouterContextProvider imported from @tanstack/react-router
fascinating-indigo
fascinating-indigoOP2mo ago
@Amos do you have a minimum working piece of code specifically for that part? I wrote this, but adminAuth is undefined:
// src/routes/__root.tsx
function InnerApp() {
const adminAuth = useAdminAuth();

return (
<RouterContextProvider router={getRouter()} context={{ adminAuth }}>
<Outlet />
</RouterContextProvider>
);
}

function App() {
return (
<ChakraProvider value={system}>
<AdminAuthProvider>
<InnerApp />
</AdminAuthProvider>
</ChakraProvider>
);
}

function RootDocument() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<App />
<TanStackRouterDevtools />
<TanStackQueryLayout />
<Scripts />
</body>
</html>
);
}
// src/routes/__root.tsx
function InnerApp() {
const adminAuth = useAdminAuth();

return (
<RouterContextProvider router={getRouter()} context={{ adminAuth }}>
<Outlet />
</RouterContextProvider>
);
}

function App() {
return (
<ChakraProvider value={system}>
<AdminAuthProvider>
<InnerApp />
</AdminAuthProvider>
</ChakraProvider>
);
}

function RootDocument() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<App />
<TanStackRouterDevtools />
<TanStackQueryLayout />
<Scripts />
</body>
</html>
);
}
harsh-harlequin
harsh-harlequin2mo ago
something like this
export function SessionProvider({ children }: { children: React.ReactNode }) {
const session = useSession()
const router = useRouter();

return (
<RouterContextProvider context={{ session }} key={session?.userId} router={router}>
{children}
</RouterContextProvider>
);
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
const session = useSession()
const router = useRouter();

return (
<RouterContextProvider context={{ session }} key={session?.userId} router={router}>
{children}
</RouterContextProvider>
);
}
export interface RouterContext {
session: Session;
}

const router = createTanstackRouter({
// ...
context: {
session: undefined as unknown as Session,
} satisfies RouterContext,
// ...
});
export interface RouterContext {
session: Session;
}

const router = createTanstackRouter({
// ...
context: {
session: undefined as unknown as Session,
} satisfies RouterContext,
// ...
});
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootDocument,
// ...
});
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootDocument,
// ...
});
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
if (!context.session.data) {
throw new Error("Not authenticated");
}
},
errorComponent: ({ error }) => {
if (error.message === "Not authenticated") {
return <SignInPage />;
}

throw error;
},
// ...
});
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
if (!context.session.data) {
throw new Error("Not authenticated");
}
},
errorComponent: ({ error }) => {
if (error.message === "Not authenticated") {
return <SignInPage />;
}

throw error;
},
// ...
});
fascinating-indigo
fascinating-indigoOP2mo ago
and where do you include SessionProvider?
harsh-harlequin
harsh-harlequin2mo ago
in root.tsx
fascinating-indigo
fascinating-indigoOP2mo ago
Same, but it gives error:
TypeError: Cannot read properties of undefined (reading 'subscribe')
I'm investigating it. Thank you!
harsh-harlequin
harsh-harlequin2mo ago
what are you using for auth?
fascinating-indigo
fascinating-indigoOP2mo ago
On login, http-only cookie is sent in response by Express. On initial page load, user session is validated by calling the API GET /me with the cookie included in request headers. At this point, I want to update auth state in React Context.
// src/routes/__root.tsx
interface RouterContext {
queryClient: QueryClient;
adminAuth: AdminAuthContext;
}

export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});

function RootComponent() {
return (
<RootDocument>
<ChakraProvider value={system}>
<AdminAuthProvider>
<InnerApp />
</AdminAuthProvider>
</ChakraProvider>
</RootDocument>
);
}

function InnerApp() {
// const adminAuth = useAdminAuth();
const router = getRouter();

return (
<RouterContextProvider router={router}>
<Outlet />
</RouterContextProvider>
);
}

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackRouterDevtools />
<TanStackQueryLayout />
<Scripts />
</body>
</html>
);
}
// src/routes/__root.tsx
interface RouterContext {
queryClient: QueryClient;
adminAuth: AdminAuthContext;
}

export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});

function RootComponent() {
return (
<RootDocument>
<ChakraProvider value={system}>
<AdminAuthProvider>
<InnerApp />
</AdminAuthProvider>
</ChakraProvider>
</RootDocument>
);
}

function InnerApp() {
// const adminAuth = useAdminAuth();
const router = getRouter();

return (
<RouterContextProvider router={router}>
<Outlet />
</RouterContextProvider>
);
}

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<TanStackRouterDevtools />
<TanStackQueryLayout />
<Scripts />
</body>
</html>
);
}
// src/router.tsx
export function getRouter() {
const queryClient = new QueryClient();

const router = createRouter({
routeTree,
context: {
queryClient,
adminAuth: undefined!,
},
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: () => <NotFound />,
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});

return router;
}

declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
// src/router.tsx
export function getRouter() {
const queryClient = new QueryClient();

const router = createRouter({
routeTree,
context: {
queryClient,
adminAuth: undefined!,
},
defaultErrorComponent: DefaultCatchBoundary,
defaultNotFoundComponent: () => <NotFound />,
});
setupRouterSsrQueryIntegration({
router,
queryClient,
});

return router;
}

declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
// src/utils/providers/admin-auth-provider.tsx
import * as React from "react";

export interface AdminAuthContext {
isInit: boolean;
isAuth: boolean;
setAuth: React.Dispatch<React.SetStateAction<boolean>>;
}

const AdminAuthContext = React.createContext<AdminAuthContext | null>(null);

export function AdminAuthProvider({ children }: { children: React.ReactNode }) {
const [isInit, setInit] = React.useState<boolean>(false);
const [isAuth, setAuthInternal] = React.useState<boolean>(false);

const setAuth = React.useCallback((value: React.SetStateAction<boolean>) => {
setAuthInternal(value);
setInit(true);
}, []);

return <AdminAuthContext.Provider value={{ isInit, isAuth, setAuth }}>{children}</AdminAuthContext.Provider>;
}

export function useAdminAuth() {
const context = React.useContext(AdminAuthContext);
if (!context) {
throw new Error("useAdminAuth must be used within an AuthProvider");
}
return context;
}
// src/utils/providers/admin-auth-provider.tsx
import * as React from "react";

export interface AdminAuthContext {
isInit: boolean;
isAuth: boolean;
setAuth: React.Dispatch<React.SetStateAction<boolean>>;
}

const AdminAuthContext = React.createContext<AdminAuthContext | null>(null);

export function AdminAuthProvider({ children }: { children: React.ReactNode }) {
const [isInit, setInit] = React.useState<boolean>(false);
const [isAuth, setAuthInternal] = React.useState<boolean>(false);

const setAuth = React.useCallback((value: React.SetStateAction<boolean>) => {
setAuthInternal(value);
setInit(true);
}, []);

return <AdminAuthContext.Provider value={{ isInit, isAuth, setAuth }}>{children}</AdminAuthContext.Provider>;
}

export function useAdminAuth() {
const context = React.useContext(AdminAuthContext);
if (!context) {
throw new Error("useAdminAuth must be used within an AuthProvider");
}
return context;
}
harsh-harlequin
harsh-harlequin2mo ago
I don't know what the .subscribe error is or if it has anything to do with this
fascinating-indigo
fascinating-indigoOP2mo ago
Yeah, it's strange. @Manuel Schiller I'd appreciate your thoughts as well. Btw, if I replace this in __root.tsx:
function InnerApp() {
// const adminAuth = useAdminAuth();
const router = getRouter();

return (
<RouterContextProvider router={router}>
<Outlet />
</RouterContextProvider>
);
}
function InnerApp() {
// const adminAuth = useAdminAuth();
const router = getRouter();

return (
<RouterContextProvider router={router}>
<Outlet />
</RouterContextProvider>
);
}
with this:
function InnerApp() {
return <Outer />;
}
function InnerApp() {
return <Outer />;
}
it works fine.
harsh-harlequin
harsh-harlequin2mo ago
why are you using getRouter and not useRouter?
fascinating-indigo
fascinating-indigoOP2mo ago
Oh.. thanks, now the error is resolved. But react context does not get merged with router context.
quickest-silver
quickest-silver2mo ago
can you please create a complete minimal example project?
quickest-silver
quickest-silver2mo ago
this wont work why do you want to store this in a react context? how would this even work when hydrating?
fascinating-indigo
fascinating-indigoOP2mo ago
On initial load of the app, in beforeLoad method of the pathless layout route, I want to fetch GET /me for checking whether user's current session is valid, and if so, to let the user in to the current guarded route; otherwise, to redirect the user to login route. As the pathless layout route's beforeLoad method is being called each time a child route is visited, I want to make sure that subsequent beforeLoad calls after the first one contains isAuthenticated set to true in their context. What would be the correct way of achieving that? Looks like my understanding of the techniques applied by the framework is shallow.
quickest-silver
quickest-silver2mo ago
just put this into the router context then not a react context
fascinating-indigo
fascinating-indigoOP2mo ago
When I return an updated part of context inside beforeLoad in the pathless layout route, child routes of that route get updated (merged) context, but next beforeLoad call of the layout route keeps having stale version of the context. Am I missing something in your approach?
quickest-silver
quickest-silver2mo ago
I mean router context not route context
fascinating-indigo
fascinating-indigoOP2mo ago
Something like this? 1. In getRouter function in router.tsx, fetch GET /me, and add the result to context object as isAuth key in createRouter function call. 2. Read context.isAuth inside beforeLoad method and act accordingly. 3. Invalidate router context, whenever user logs in, logs out explicitly, or logs out implicitly due to getting 401 error due to cookie session being expired.
rival-black
rival-black2mo ago
I think this is what Manual means:
// file: _root.tsx

interface MyRouterContext {
queryClient: QueryClient

trpc: TRPCOptionsProxy<TRPCRouter>

user: Awaited<ReturnType<typeof getUser>> | null
isAuthenticated: boolean
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async () => {
const user = await getUser()

return {
user,
isAuthenticated: !!user,
}
},
// file: _root.tsx

interface MyRouterContext {
queryClient: QueryClient

trpc: TRPCOptionsProxy<TRPCRouter>

user: Awaited<ReturnType<typeof getUser>> | null
isAuthenticated: boolean
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async () => {
const user = await getUser()

return {
user,
isAuthenticated: !!user,
}
},
this will always have updated data. at least that's how I've implemented it. You can then use component's own context to access the user.
fascinating-indigo
fascinating-indigoOP2mo ago
Thanks for sharing. It surely works, but it repeats getUser call on each route navigation, because the root route's beforeLoad gets called on each route navigation. I want to prevent unnecessary load directed to backend.
rival-black
rival-black2mo ago
right. you can cache it with queryClient and invalidate it when user logs out or time based revalidation. @ziyaddinsadygly did you find the solution?
fascinating-indigo
fascinating-indigoOP2mo ago
Nope. I'm exploring equivalent mechanisms in the other SSR frameworks.
continuing-cyan
continuing-cyan2mo ago
You can create a provider and use it on client side to avoid call beforeLoad to check user.
fascinating-indigo
fascinating-indigoOP2mo ago
Yeah, looks like context provider on client side will do the job, because, apparently, CSR-based pages do not require "instant" redirection, and SSR-based pages will be fine by just loading public data (that is, data that does not require auth), at least for now. Thank you for your reply, Carlos.

Did you find this page helpful?