react-navigationreact-navigation-v5react-navigation-stackreact-navigation-bottom-tabreact-navigation-v6

How can I navigate to a nested stack screen inside a sibling tab 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 (i.e. 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 (i.e. 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 MembershipStack, since 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 MembershipStack, there'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 most, nothing 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 useLayoutEffect, the MembershipHomeScreen still renders nonetheless to show a transition animation between MembershipHomeScreen and PayMembershipScreen.

Same behavior with useEffect, except 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:

However:

Turns out, if I go back to MembershipHomeScreen once I'm inside PayMembershipScreen, then 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 find a solution that achieves the expected outcome? To sum up:

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


Solution

  • I couldn't get your solution to work for me. Instead, I used a version of your proposed remedy #2, except that inside of the intermediary screen (which would be MembershipHomeScreen in your example, I used useEffect, looked for the parameter that I had specified in the previous screen (HomeScreen, in your example), and then called navigation.push(), rather than navigation.navigate(). Note that push() is only available when the navigator is a StackNavigator. The parameters are the same, though.

    There is a quick flash of the intermediary screen, but no freeze. Although, it should be noted that in my case, the intermediary screen is very lightweight, with no effects other than logging a Google Analytics event asynchronously.

    HomeScreen:

    const payPressHandler = () => {
        navigation.navigate("MembershipStack", 
            {
                screen: "MembershipHomeScreen", 
                params: {
                    navToNested: {
                        name: 'PayMembershipScreen',
                        params: {
                            customParamForDestinationScreen: 'foo'
                        }
                    }
                }
            }
        );
    }
    

    MembershipHomeScreen:

    useEffect(() => {
        if (route?.params?.navToNested) {
            const navTo = route.params.navToNested;
            navigation.push(navTo.name, navTo.params);
        }
    }, [route]);
    

    This still doesn't feel like the cleanest, most ideal solution, but it works, more or less. It is what I am using in my own app for now. Hopefully, it is of some help to the original poster, or to others who read this.