I’m trying to implement a global <Redirector /> component in Expo Router to control the navigation flow of my app.

The logic depends on:

  • Authentication state

  • Whether the user completed onboarding

  • Whether the user passed the paywall

  • Some route groups (tabs), (settings), (capsule)

  • etc.

I attempted the solution below, and although it works most of the time, it occasionally causes unexpected navigation bugs. Fixing those bugs is time-consuming, so I’m wondering whether my approach is fundamentally wrong or if there’s a recommended pattern I should follow.

I’ve read the official Expo Router docs about redirects, but they’re not detailed enough for multi-layered logic like auth/onboarding/paywall and etc.

Here is my current implementation:

// Redirector.tsx
import { useAuth } from "@/hooks/useAuth";
import { getHasOnboarded, getHasPaywalled } from "@/utils/storage";
import {
  Redirect,
  usePathname,
  useRootNavigationState,
  useSegments,
} from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, useMemo, useState } from "react";

SplashScreen.preventAutoHideAsync().catch(() => {});

const Redirector = ({ children }: { children: React.ReactElement }) => {
  const [hasOnboarded, setHasOnboarded] = useState<boolean | undefined | null>(
    undefined
  );
  const [hasPaywalled, setHasPaywalled] = useState<boolean | undefined | null>(
    undefined
  );
  const { auth, loading } = useAuth();
  const pathname = usePathname();
  const navState = useRootNavigationState();
  const segments = useSegments();

  useEffect(() => {
    (async () => {
      const hasO = await getHasOnboarded();
      const hasP = await getHasPaywalled();
      setHasOnboarded(hasO);
      setHasPaywalled(hasP);
    })();
  }, []);

  const ready = useMemo(
    () =>
      Boolean(navState?.key) &&
      !loading &&
      hasOnboarded !== undefined &&
      hasPaywalled !== undefined,
    [navState?.key, loading, hasOnboarded, hasPaywalled]
  );

  useEffect(() => {
    if (ready) {
      SplashScreen.hideAsync().catch(() => {});
    }
  }, [ready]);

  const onOnboarding = pathname.startsWith("/onboarding");
  const onPaywalling = pathname.startsWith("/paywall");
  const onTabs = segments[0] === "(tabs)";
  const onCapsule = segments[0] === "(capsule)";
  const onSettings = segments[0] === "(settings)";
  const onAuth =
    pathname.startsWith("/register") ||
    pathname.startsWith("/forgot-password") ||
    pathname.startsWith("/login");
  const onLogin = pathname.startsWith("/login");

  if (!navState?.key || loading) {
    return null;
  }

  if (!hasOnboarded && !onPaywalling) {
    if (!onOnboarding) return <Redirect href="/onboarding" />;
    return children;
  }

  if (!auth && hasOnboarded && !hasPaywalled) {
    if (!onPaywalling) return <Redirect href={"/paywall"} />;
    return children;
  }

  if (auth && !onPaywalling && !onCapsule && !onSettings) {
    if (!onTabs) return <Redirect href={"/(tabs)"} />;
    return children;
  }

  if (!auth && hasOnboarded && !onAuth && hasPaywalled) {
    if (!onLogin) return <Redirect href="/login" />;
    return children;
  }

  return children;
};

export default Redirector;

And I use it like this in RootLayout:

// _layout.tsx
...
  <GlobalLayout>
    <Redirector>
      <Stack>
        ...
      </Stack>
    </Redirector>
  </GlobalLayout>
...

My question

What is the correct or recommended way to implement global redirect logic (auth/onboarding/paywall) in Expo Router?

Is there a more reliable pattern or best practice for handling these flows without causing navigation inconsistencies?

0

Your Reply

By clicking “Post Your Reply”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.