Skip to main content

Preventing Infinite Redirect Loops in Expo Router v4 Authentication

 If you are migrating to or starting a fresh project with Expo Router v4, you have likely encountered the "White Screen of Death" or the console screaming Maximum update depth exceeded.

This usually occurs when implementing protected routes. You follow the documentation, set up a useEffect to check for a user session, and issue a router.replace(). Suddenly, your app enters a render loop, or deep links fail to resolve because the navigation state isn't ready when the auth check fires.

Here is the root cause analysis and a production-grade implementation to handle authentication flows without race conditions.

The Root Cause: Fighting the Navigation Cycle

The infinite loop happens because of a conflict between React's render cycle and the Router's navigation lifecycle.

  1. The Trigger: Your Root Layout mounts. The useEffect fires, detects no user, and calls router.replace('/login').
  2. The Action: The router unmounts the current screen and mounts the Login screen.
  3. The Conflict: If your authentication logic resides in a component that remounts during this navigation (or if the segments array changes trigger an immediate re-evaluation before the navigation settles), the useEffect fires again.
  4. The Result: The router tries to replace /login with /login, causing a state update, triggering the effect, ad infinitum.

Furthermore, during deep linking (e.g., opening a specific profile URL from an email), the navigation container might not be fully hydrated when your auth check runs. Redirecting at this exact millisecond aborts the deep link resolution, leaving the user on a blank screen.

The Architecture: Group-Based Routing

To solve this cleanly in Expo Router v4, we rely on Route Groups ((auth) and (tabs)/(app)). This allows us to segregate public and private logic physically in the file system, simplifying the condition checks in our middleware.

Directory Structure:

app/
├── (app)/
│   ├── _layout.tsx
│   └── index.tsx
├── (auth)/
│   ├── _layout.tsx
│   ├── sign-in.tsx
│   └── sign-up.tsx
├── _layout.tsx       <-- Auth Logic lives here
└── +not-found.tsx

The Solution

We need three components:

  1. A strictly typed SessionContext.
  2. A custom hook (useProtectedRoute) to encapsulate navigation logic.
  3. The Root _layout to mount the provider.

1. The Session Provider

Avoid putting side effects in your provider. Its only job is to expose the state and toggle functions.

import React, { createContext, useContext, useStorageState } from 'react';
import { useStorageState } from './useStorageState'; // Custom hook wrapping Async Storage

interface SessionContextType {
  signIn: () => void;
  signOut: () => void;
  session?: string | null;
  isLoading: boolean;
}

const AuthContext = createContext<SessionContextType>({
  signIn: () => null,
  signOut: () => null,
  session: null,
  isLoading: false,
});

export function useSession() {
  const value = useContext(AuthContext);
  if (process.env.NODE_ENV !== 'production') {
    if (!value) {
      throw new Error('useSession must be wrapped in a <SessionProvider />');
    }
  }
  return value;
}

export function SessionProvider({ children }: { children: React.ReactNode }) {
  // Replace with your actual auth logic (Firebase, Supabase, etc.)
  const [[isLoading, session], setSession] = useStorageState('session');

  return (
    <AuthContext.Provider
      value={{
        signIn: () => {
          // Perform login logic here
          setSession('xxx');
        },
        signOut: () => {
          setSession(null);
        },
        session,
        isLoading,
      }}>
      {children}
    </AuthContext.Provider>
  );
}

2. The Root Layout and Protection Hook

This is where we prevent the loop. We must check where the user is currently located (segments) against where they are allowed to be.

We do not use a Loading spinner that blocks the entire app render, as this kills the splash screen animation provided by Expo. Instead, we allow the layout to render the Slot but intercept navigation before the screen paints.

import { Slot, useRouter, useSegments } from 'expo-router';
import { useEffect } from 'react';
import { SessionProvider, useSession } from '../ctx'; // Path to your context

function useProtectedRoute() {
  const { session, isLoading } = useSession();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;

    const inAuthGroup = segments[0] === '(auth)';

    if (
      // If the user is not signed in and the initial segment is not anything in the auth group.
      !session &&
      !inAuthGroup
    ) {
      // Redirect to the sign-in page.
      router.replace('/(auth)/sign-in');
    } else if (session && inAuthGroup) {
      // Redirect away from the sign-in page.
      router.replace('/(app)');
    }
  }, [session, segments, isLoading]);
}

function RootLayoutNav() {
  useProtectedRoute();

  return <Slot />;
}

export default function RootLayout() {
  return (
    <SessionProvider>
      <RootLayoutNav />
    </SessionProvider>
  );
}

3. Handling the Async Storage Hook (Helper)

For the SessionProvider above to be valid, here is a simplified, non-blocking storage hook using expo-secure-store.

import * as SecureStore from 'expo-secure-store';
import * as React from 'react';
import { Platform } from 'react-native';

type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];

function useAsyncState<T>(
  initialValue: [boolean, T | null] = [true, null]
): UseStateHook<T> {
  return React.useReducer(
    (state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
    initialValue
  ) as UseStateHook<T>;
}

export async function setStorageItemAsync(key: string, value: string | null) {
  if (Platform.OS === 'web') {
    try {
      if (value === null) {
        localStorage.removeItem(key);
      } else {
        localStorage.setItem(key, value);
      }
    } catch (e) {
      console.error('Local storage is unavailable:', e);
    }
  } else {
    if (value === null) {
      await SecureStore.deleteItemAsync(key);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  }
}

export function useStorageState(key: string): UseStateHook<string> {
  const [state, setState] = useAsyncState<string>();

  React.useEffect(() => {
    if (Platform.OS === 'web') {
      try {
        if (typeof localStorage !== 'undefined') {
          setState(localStorage.getItem(key));
        }
      } catch (e) {
        console.error('Local storage is unavailable:', e);
      }
    } else {
      SecureStore.getItemAsync(key).then((value) => {
        setState(value);
      });
    }
  }, [key]);

  const setValue = React.useCallback(
    (value: string | null) => {
      setState(value);
      setStorageItemAsync(key, value);
    },
    [key]
  );

  return [state, setValue];
}

Why This Works

The critical logic lives here:

const inAuthGroup = segments[0] === '(auth)';

if (!session && !inAuthGroup) {
  router.replace('/(auth)/sign-in');
}

By checking !inAuthGroup, we ensure the router.replace command is only issued once.

  1. Initial Load: User is unauthenticated. App tries to load /(app)/index.
  2. Check: !session is true. !inAuthGroup is true (segment is (app)).
  3. Action: Redirect to /(auth)/sign-in.
  4. Re-render: useProtectedRoute runs again.
  5. Check: !session is true. !inAuthGroup is false (segment is now (auth)).
  6. Action: No operation. The loop is broken.

Deep Link Safety

By ensuring isLoading returns early, we prevent the router from reacting before the session state is retrieved from SecureStore. Expo Router's Slot component handles the waiting period gracefully, keeping the native splash screen visible (if configured in app.json) or showing a blank frame only momentarily until the hydration completes, effectively preserving the intended deep link path if the user turns out to be authenticated.

Conclusion

Routing in React Native is fundamentally different from the web. You are not just changing a URL string; you are manipulating a native navigation stack. By isolating your public and private routes into File System Groups ((auth) vs (app)) and checking your current segment before redirecting, you ensure your app respects both the authentication state and the navigation lifecycle.