T
TanStack•7mo ago
mute-gold

help refactoring context with useEffect into layout `beforeload`?

I'm trying to integreate openauth into a router project, and have been at this on and off for a week or so now. i've come up with a solution, but it's suboptimal and involves setting/getting jotai stores outside of react which seems to be generally discouraged. basically there's this example i'm trying to roughly port. some of the things tripping me up, but here's my thought process, and would be deeply grateful to any willing to give some thoughts - there's a bunch of logic happening in a useEffect w/ no deps. this tells me that I would prefer to just pull this logic into a beforeload. i'll pull into __root because, for now, i want auth data accessible at all my pages. - ok, i pull the logic out, but now i need to set some values which, in the example, are state values. i'm not in a component anymore so i need a solution here -> use context to communicate btwn route loader and context - i wrap my whole tree in an auth context - the beforeload is at my _root, so now it's higher in the tree than everything else. this means that the auth context provider renders beneath it. seems like this defeats the solution of pulling it into context. not to mention that this is sort of where my brain starts go a million different ways and i don't have a good model of how to solve this. i'm just wondering, is there a pattern for doing this type of thing? i'm sorry if this is vague and confusing, i'm just looking for any thoughts on how someone would go about this. my current solution is really not something i'd like to use, so looking for the "proper" way to handle this.
33 Replies
stormy-gold
stormy-gold•7mo ago
can you please share your actual code that you ended up with? even if its not working or just pseudocode right now its just a lot of text šŸ˜„
mute-gold
mute-goldOP•7mo ago
sure, 1 sec, i'll have to step back a couple steps yea my bad, 1min __root.tsx
interface AuthenticatedProviderContext {
auth: {
userId: UserId;
loaded: boolean;
loggedIn: boolean;
// ...
};
}
// ...
export const Route = createRootRouteWithContext<AuthenticatedProviderContext>()(
{
validateSearch: AuthenticationCallbackData,
component: RootComponent,
beforeLoad: async (opts) => {
const { code, state } = opts.search;
// in a proper solution i don't want to use jotai stores. would rather use atoms, ideally those supplied via context so they can already have been instantiated with useAtom() by the time i get them here

if (code && state) {
// some irrelevant stuff + redirect end of block
} else {
const access = authStore.get(accessTokenAtomWithStorage);
const refresh = authStore.get(refreshTokenAtomWithStorage);
if (!refresh) {
return;
}

const next = await openauth.refresh(refresh, { access });

if (next.err) {
if (next.err instanceof InvalidRefreshTokenError) {
console.log("invalid refresh token");
}
return;
}

if (next.tokens?.access) {
authStore.set(accessTokenAtomWithStorage, next.tokens.access);
}
if (next.tokens?.refresh) {
authStore.set(refreshTokenAtomWithStorage, next.tokens.refresh);
}
authStore.set(authenticationAtom, await getUserId(), true);
}
},
}
);

function Providers(props: ParentComponentProps) {
const { code, state } = Route.useSearch();

return (
<AuthProvider code={code} state={state}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{props.children}
</ThemeProvider>
</AuthProvider>
);
}
interface AuthenticatedProviderContext {
auth: {
userId: UserId;
loaded: boolean;
loggedIn: boolean;
// ...
};
}
// ...
export const Route = createRootRouteWithContext<AuthenticatedProviderContext>()(
{
validateSearch: AuthenticationCallbackData,
component: RootComponent,
beforeLoad: async (opts) => {
const { code, state } = opts.search;
// in a proper solution i don't want to use jotai stores. would rather use atoms, ideally those supplied via context so they can already have been instantiated with useAtom() by the time i get them here

if (code && state) {
// some irrelevant stuff + redirect end of block
} else {
const access = authStore.get(accessTokenAtomWithStorage);
const refresh = authStore.get(refreshTokenAtomWithStorage);
if (!refresh) {
return;
}

const next = await openauth.refresh(refresh, { access });

if (next.err) {
if (next.err instanceof InvalidRefreshTokenError) {
console.log("invalid refresh token");
}
return;
}

if (next.tokens?.access) {
authStore.set(accessTokenAtomWithStorage, next.tokens.access);
}
if (next.tokens?.refresh) {
authStore.set(refreshTokenAtomWithStorage, next.tokens.refresh);
}
authStore.set(authenticationAtom, await getUserId(), true);
}
},
}
);

function Providers(props: ParentComponentProps) {
const { code, state } = Route.useSearch();

return (
<AuthProvider code={code} state={state}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{props.children}
</ThemeProvider>
</AuthProvider>
);
}
so this works now. i'm basically skipping out on context, and anywhere i need to access any of the relevant data, i can just import the atoms and useAtom(accessTokenAtom) for example, and it's populated. but i'd prefer to not opt by using stores like i am here, and pass around basically shared values (be it state or jotai atoms, i just used this because they have nice built in local storage persistence)
stormy-gold
stormy-gold•7mo ago
be aware that beforeLoad would run upon each navigation so you would refresh each time you click a link which can incur some delays
mute-gold
mute-goldOP•7mo ago
I did notice that- would you recommend putting an auth initialization somewhere other than beforeLoad then?
stormy-gold
stormy-gold•7mo ago
either outside of beforeLoad or you cache it somehow so you have a cheap way to skip over it in beforeLoad
mute-gold
mute-goldOP•7mo ago
what would be the router equivalent of a useEffect inside of a context provider that mounts at the root of the app? my goal is to port this example but make use of what ts router provides and everything i've seen re: react is, avoid useEffect at all costs
stormy-gold
stormy-gold•7mo ago
it needs to be async, right?
mute-gold
mute-goldOP•7mo ago
yea are you going to say query
stormy-gold
stormy-gold•7mo ago
ha šŸ˜‰ no
mute-gold
mute-goldOP•7mo ago
have had that in the back of my head this whole time lol
stormy-gold
stormy-gold•7mo ago
i mean you can use it for caching here but maybe read this thread https://discord.com/channels/719702312431386674/1339690743580721152/1340006793874636914 you are not affected by serialization issues since you are just using router, not start. right?
mute-gold
mute-goldOP•7mo ago
correct what i don't get is, is there not an established pattern for doing this in router? like this feels like a very common use case
stormy-gold
stormy-gold•7mo ago
it really depends what you have. sometimes you need to use some providers given to you by a library how did your solution look like before? with useEffect etc
mute-gold
mute-goldOP•7mo ago
GitHub
openauth/examples/client/react/src/AuthContext.tsx at master Ā· open...
ā–¦ Universal, standards-based auth provider. Contribute to openauthjs/openauth development by creating an account on GitHub.
mute-gold
mute-goldOP•7mo ago
pretty simple just do everything i did but in a useeffect, pass down the requisite data as values on the provider then i turned it into this beforeload, jotai stores with local storage disaster that i now have on my hands
stormy-gold
stormy-gold•7mo ago
and what triggered the useEffect?
mute-gold
mute-goldOP•7mo ago
in this example?
stormy-gold
stormy-gold•7mo ago
yeah
mute-gold
mute-goldOP•7mo ago
it renders providers at the root i believe so runs when the app mounts
stormy-gold
stormy-gold•7mo ago
so this executes just once?
mute-gold
mute-goldOP•7mo ago
as far as i can tell. i never set up this exact thing. but the example there is a pretty simple vite react app, so my understanding is it should only run on page load
stormy-gold
stormy-gold•7mo ago
so this does not sound like a situation where I would "optimize" away the useEffect
mute-gold
mute-goldOP•7mo ago
Ok fair enough I'm back to this thread with another question. I've been battling with this some more, went ahead and didn't move away from useEffect, though I still want to check auth checks in beforeLoad. I had a tough time for a while getting this to work in beforeLoad, and hit a point where it looks like my Router Context is not what I expect. I decided to give it a go by using an early return with the Navigate component as an alternative to beforeLoad + redirect, and was able to achieve the goal. It's curious though, because it seems like the two options should behave pretty similarly, but at different stages with respect to the current component's mount cycle. Here's the code
export const Route = createFileRoute("/_app")({
validateSearch: AuthenticationCallback,
beforeLoad: (options) => {
const auth = options.context.auth;

if (!auth?.loggedIn) {
console.log("should be redirecting to sign-in");
// throw redirect({
// to: "/sign-in",
// });
}
},
component: RouteComponent,
});

function RouteComponent() {
const auth = useAuth();

if (!auth.loggedIn) {
return <Navigate to="/sign-in" />;
}

return (
<>
<Navigation />
<main className="min-h-full flex flex-col px-8 pb-48 pt-6 text-center gap-4 sm:min-w-96 w-full max-w-screen-2xl">
<Outlet />
</main>
</>
);
}
export const Route = createFileRoute("/_app")({
validateSearch: AuthenticationCallback,
beforeLoad: (options) => {
const auth = options.context.auth;

if (!auth?.loggedIn) {
console.log("should be redirecting to sign-in");
// throw redirect({
// to: "/sign-in",
// });
}
},
component: RouteComponent,
});

function RouteComponent() {
const auth = useAuth();

if (!auth.loggedIn) {
return <Navigate to="/sign-in" />;
}

return (
<>
<Navigation />
<main className="min-h-full flex flex-col px-8 pb-48 pt-6 text-center gap-4 sm:min-w-96 w-full max-w-screen-2xl">
<Outlet />
</main>
</>
);
}
So I'm getting that console log in my beforeLoad, but in the actual component mount, i'm signed in so i never hit the Navigate mount I can provide more code, just not totally sure which piece are most relevant (I also wrap my entire app in a check to see if the auth step has finished running. if it hasn't i return null so i don't show any stale state)
stormy-gold
stormy-gold•7mo ago
so what are you saying here? that the router context and the auth hook have different data? then you probably need to router.invalidate() after you updated the auth
mute-gold
mute-goldOP•7mo ago
yea I am doing that
mute-gold
mute-goldOP•7mo ago
this is the sequence of events happening
No description
mute-gold
mute-goldOP•7mo ago
in the beforeLoad i'm not actually invoking the redirect because that will break the behavior of the in component check
stormy-gold
stormy-gold•7mo ago
how are you updating the auth object in context?
mute-gold
mute-goldOP•7mo ago
useEffect(() => {
async function init() {
const finishAuthenticating = (userId: UserId) => {
setAuthentication(userId, true);
console.log("invalidating router");
router.invalidate();
};

const strategy = code && state ? "initialize" : "refresh";

// primary authentication flow
switch (strategy) {
case "initialize": // TODO: move into callback route
const results = await callback({ code, state });

setRefreshToken((prev) => results.refreshToken ?? prev);
setAccessToken((prev) => results.refreshToken ?? prev);

results.after?.();
return;
case "refresh":
if (!refreshToken) {
finishAuthenticating(undefined);
return;
}

const next = await openauth.refresh(refreshToken, {
access: accessToken,
});

if (next.err) {
finishAuthenticating(undefined);
return; // TODO: handle
}

setAccessToken((prev) => next.tokens?.access ?? prev);
setRefreshToken((prev) => next.tokens?.refresh ?? prev);

const userId = await getUserId();

finishAuthenticating(userId);
return;
}
}

init();
}, []);
useEffect(() => {
async function init() {
const finishAuthenticating = (userId: UserId) => {
setAuthentication(userId, true);
console.log("invalidating router");
router.invalidate();
};

const strategy = code && state ? "initialize" : "refresh";

// primary authentication flow
switch (strategy) {
case "initialize": // TODO: move into callback route
const results = await callback({ code, state });

setRefreshToken((prev) => results.refreshToken ?? prev);
setAccessToken((prev) => results.refreshToken ?? prev);

results.after?.();
return;
case "refresh":
if (!refreshToken) {
finishAuthenticating(undefined);
return;
}

const next = await openauth.refresh(refreshToken, {
access: accessToken,
});

if (next.err) {
finishAuthenticating(undefined);
return; // TODO: handle
}

setAccessToken((prev) => next.tokens?.access ?? prev);
setRefreshToken((prev) => next.tokens?.refresh ?? prev);

const userId = await getUserId();

finishAuthenticating(userId);
return;
}
}

init();
}, []);
this is my useEffect that runs in the context. then i set my atoms, and they get passed down like
return (
<AuthContext.Provider
value={{
...authentication, // this is the auth object
async login() {
// ...
},
logout() {
// ...
},
}}
>
{props.children}
</AuthContext.Provider>
return (
<AuthContext.Provider
value={{
...authentication, // this is the auth object
async login() {
// ...
},
logout() {
// ...
},
}}
>
{props.children}
</AuthContext.Provider>
'refresh' case in the switch is the relevant one here
stormy-gold
stormy-gold•7mo ago
and how do you pass it into router context?
mute-gold
mute-goldOP•7mo ago
// __root
interface AuthenticatedProviderContext {
// The ReturnType of your useAuth hook or the value of your AuthContext
auth: {
userId: UserId;
loaded: boolean;
loggedIn: boolean;
logout: () => void;
login: () => Promise<void>;
};
}

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

function Providers(props: ParentComponentProps) {
const { code, state } = Route.useSearch();

return (
<AuthProvider code={code} state={state}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{props.children}
</ThemeProvider>
</AuthProvider>
);
}

function RootComponent() {
return (
<Providers>
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
</Providers>
);
}
// __root
interface AuthenticatedProviderContext {
// The ReturnType of your useAuth hook or the value of your AuthContext
auth: {
userId: UserId;
loaded: boolean;
loggedIn: boolean;
logout: () => void;
login: () => Promise<void>;
};
}

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

function Providers(props: ParentComponentProps) {
const { code, state } = Route.useSearch();

return (
<AuthProvider code={code} state={state}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{props.children}
</ThemeProvider>
</AuthProvider>
);
}

function RootComponent() {
return (
<Providers>
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
</Providers>
);
}
// main
export const router = createRouter({
routeTree,
context: {
// auth will initially be undefined
// We'll be passing down the auth state from within a React component
auth: undefined!,
},
defaultPreload: "intent",
});

// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

const rootElement = document.getElementById("app");

if (!rootElement) {
throw new Error("app root must be present");
}

if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}
// main
export const router = createRouter({
routeTree,
context: {
// auth will initially be undefined
// We'll be passing down the auth state from within a React component
auth: undefined!,
},
defaultPreload: "intent",
});

// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

const rootElement = document.getElementById("app");

if (!rootElement) {
throw new Error("app root must be present");
}

if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}
stormy-gold
stormy-gold•7mo ago
i still fail to see where auth is set on router context
mute-gold
mute-goldOP•7mo ago
Dang, right. I just reread the docs on auth w/ context and i messed up a bunch so if i have to render my auth provider above the <Router>, like this https://github.com/TanStack/router/blob/main/examples/react/authenticated-routes/src/main.tsx does that mean that i can't get access to search params inside the auth provider?

Did you find this page helpful?