How can I navigate to a nested stack screen in a sibling tab navigator while preserving its initial screen? (React Navigation)

The Code:

I’m using React Navigation 6 with the following hierarchy:

MainTabNavigator
    HomeStack
        HomeScreen (HomeStack initial screen, contains a "Pay" button)
        OtherScreen
    MembershipStack
        MembershipHomeScreen (MembershipStack initial screen)
        PayMembershipScreen (should always navigate back to MembershipHomeScreen)

The App launches into HomeScreen (inside HomeStack), so the MembershipStack tab won’t be loaded yet.

There is a “Pay” button inside HomeScreen that redirects to PayMembershipScreen inside MembershipStack.

Code of the HomeScreen with the “Pay” button press handler:

const HomeScreen = ({navigation}) => {

    const payPressHandler = useCallback(() => {
        // The parent navigator here is MainTabNavigator
        navigation.getParent().navigate("MembershipStack", { screen: "PayMembershipScreen" })
    }, [navigation])

    return (
        <TouchableOpacity onPress={payPressHandler}>
            <Text>Go to Pay screen!</Text>
        </TouchableOpacity>
    )
}

The “Pay” button inside HomeScreen does navigate to PayMembershipScreen with this code.

The Problem:

When the MembershipStack tab has not yet been loaded (ie when the App just launched), the following happens:

  1. User clicks the “Pay” button on HomeScreen.
  2. App navigates to PayMembershipScreen.
  3. PayMembershipScreen becomes the initial screen on MembershipStack.
  4. User can’t go back to MembershipHomeScreen from PayMembershipScreen.

The question is: how can I navigate from HomeScreen directly to PayMembershipScreen and after that be able to go back to MembershipHomeScreen (ie have it available as the initial screen in the MembershipStack history)?

What I’ve tried so far:

1. Setting lazy: false on the MainTabNavigator options.

This approach does make sure that MembershipHomeScreen is always the initial screen on MembershipStacksince all stacks (and their initial screens) will be loaded when the App launches. However this comes with a noticeable drawback in performance, since there’s at least 5 tabs in the actual project.

Also, since MembershipHomeScreen is already focused inside MembershipStackthere’s a weird MembershipHomeScreen to PayMembershipScreen transition animation when the “Pay” button on HomeScreen is pressed. I just want the user to see a transition from HomeScreen to PayMembershipScreen at mostnothing flashing inbetween.


2. Define a param on MembershipHomeScreen to indicate when I want to redirect to PayMembershipScreen.

On this approach, I’m using a boolean param on MembershipHomeScreen called redirectToPayment:

Code inside MembershipHomeScreen:

const MembershipHomeScreen = ({navigation, route}) => {
    const { redirectToPayment = false } = route.params

    const redirectIfNeeded = useCallback(() => {
        if (redirectToPayment) {
            // reset the param
            navigation.setParams({ redirectToPayment: false })
            // redirect to the desired screen
            navigation.navigate("PayMembershipScreen")
        }
    }, [redirectToPayment, navigation])

    // Using layout effect to avoid rendering MembershipHomeScreen when redirecting.
    useLayoutEffect(redirectIfNeeded)

    return (
        <Text>
            This is just the membership home screen,
            not the target screen of the "Pay" button.
        </Text>
    )
}

And on the HomeScreen:

const payPressHandler = useCallback(() => {
    navigation
        .getParent()
        .navigate(
            "MembershipStack",
            { screen: "MembershipHomeScreen", params: { redirectToPayment: true } }
        )
}, [navigation])

The use of React’s useLayoutEffect comes with the known drawback of freezing the screen since it will leave any rendering tasks on hold while it’s running. I’m able to notice a 2 seconds freeze in the HomeScreen when I press the “Pay” button on a 4GB RAM Moto G7…

…and even after using useLayoutEffectthe MembershipHomeScreen still renders nonetheless to show a transition animation between MembershipHomeScreen and PayMembershipScreen.

Same behavior with useEffectexcept it renders the MembershipHomeScreen instead of a 2 seconds freeze.


3. Using React Navigation’s dispatch function to customize the route history.

On this approach, I intend to dispatch a custom action that does the following to the navigation state of MainTabNavigator:

Before:

index: 0
routes: [
    0: {
        name: "HomeStack",
        state: {
            index: 0,
            routes: [
                0: {
                    name: "Home" // <-- currently active screen
                }
            ]
        }
    },
    1: {
        name: "MembershipStack",
        state: undefined // <-- this is an unloaded tab
    }
]

After:

index: 1
routes: [
    0: {
        name: "HomeStack",
        state: {
            index: 0,
            routes: [
                0: {
                    name: "Home"
                }
            ]
        }
    },
    1: {
        name: "MembershipStack",
        state: {
            index: 1,
            routes: [
                0: {
                    name: "MembershipHomeScreen"
                },
                1: {
                    name: "PayMembershipScreen" // <-- currently active screen
                }
            ]
        }
    },
]

Here’s the code I’m using inside the HomeScreen for that:

const payPressHandler = useCallback(() => {

        navigation.getParent().dispatch(state => {
            const membershipTabIndex = state.routes.findIndex(r => r.name === "MembershipStack")

            // get the current navigation state inside the MembershipStack
            let membershipTabState = state.routes[membershipTabIndex].state

            // point to PayMembershipScreen without overriding the initial screen on that tab
            if (!membershipTabState) {
                // tab is unloaded, so just set the ideal state
                membershipTabState = { index: 1, routes: [{ name: "MembershipHomeScreen" }, { name: "PayMembershipScreen" }] }
            } else {
                // tab already has a navigation state, so we'll point to PayMembershipScreen 
                // if it's loaded in the stack. Otherwise, we'll add it and point to it.
                let payMembershipScreenIndex = membershipTabState.routes.findIndex(r => r.name === "PayMembershipScreen")

                if (payMembershipScreenIndex === -1) {
                    payMembershipScreenIndex = membershipTabState.routes.push({ name: "PayMembershipScreen" }) - 1
                }

                membershipTabState.index = payMembershipScreenIndex
            }

            // update the MembershipStack tab with the new state
            const routes = state.routes.map((r, i) => i === membershipTabIndex ? { ...r, state: membershipTabState} : r)

            // update the MainTabNavigator state
            return CommonActions.reset({
                ...state,
                routes,
                index: membershipTabIndex
            })
        })
}, [navigation])

That code almost accomplishes the expected outcome:

  • It navigates from HomeScreen to PayMembershipScreen successfuly.
  • The user can go back to MembershipHomeScreen from PayMembershipScreen.
  • MembershipHomeScreen does not render in-between during the transition.

However:

  • It won’t work a second time if you do go back to MembershipHomeScreen from PayMembershipScreen.

Turns out, if I go back to MembershipHomeScreen once I’m inside PayMembershipScreenthen go back to the HomeStack and press the “Pay” button again, it will now navigate to MembershipHomeScreen instead of PayMembershipScreen.

Additionally, the MembershipHomeScreen will now display a disabled back button in the header (probably a bug).

This last approach is so far the closest to getting the desired outcome, so I really hope that it only needs a fix in the logic and it’s not really a bug.


Summary:

Is anyone able to find a solution that achieves the expected outcome? To sum up:

  • It should make the “Pay” button navigate from HomeScreen to PayMembershipScreen.
  • The user should be able to go back to MembershipHomeScreen once they’re in PayMembershipScreen.
  • The MembershipHomeScreen should not flash in the transition from HomeScreen to PayMembershipScreen.
  • The screen should not freeze inbetween the transitions (no use of useLayoutEffect).
  • Don’t load all tabs on App launch (it’s too much burden on the actual 5-tab project).
  • If the user navigates back to MembershipHomeScreen once they’re inPayMembershipScreenpressing the “Pay” button on HomeScreen again should open the PayMembershipScreen again (no buggy behavior).

Minimal reproducible example:

Here’s a snack with all of the approaches mentioned. Please check it out and use it as a playground for your solution!

https://snack.expo.dev/@ger88555/tabs-with-stacks-and-a-button

Leave a Comment