Blog

Stop Breaking Your Expo Router Tabs

February 2, 2026 9 min read

How I stopped Expo Router from mounting a fresh tabs subtree that only looked like the same home screen in a mixed stack-plus-tabs app.

Stop Breaking Your Expo Router Tabs
ExpoExpo RouterReact NativeNavigationArchitecture

How I Fixed Duplicated Tabs in Expo Router

Expo Router is pretty easy when your tabs are the whole app.

It gets more interesting when your app has tabs, but also has full-screen routes outside the tabs for things like detail screens, processing screens, result screens, purchase screens, or onboarding.

That was my setup.

And the bug was not really "Expo Router is broken".

The bug was that I was accidentally creating a second tabs subtree.

The app looked like it went back home, but it was not the same tabs instance.

That is a very different problem from simply landing on the wrong screen.

I was landing on a screen that looked correct, but the underlying tab tree was fresh.

So the real failure mode was: duplicated tabs, same-looking UI, wrong instance.

That is why the feed could reset, tab-specific state could disappear, and the app could feel inconsistent even though the user was "back on home".

This article exists because I hit exactly that problem and had to clean it up.

My app did not have a simple tabs-only graph

The important detail was this:

my tab navigator was not the whole app.

I had a root stack, and the tabs were only one branch inside it.

<Stack>
  <Stack.Screen name="(tabs)" />
  <Stack.Screen name="details/[id]" />
  <Stack.Screen name="processing/[id]" />
  <Stack.Screen name="result/[id]" />
  <Stack.Screen name="purchase" />
</Stack>

That structure was intentional.

I wanted details, processing, and result outside the tabs so they could take over the whole screen and hide the tab bar.

That part was fine.

The problem started when I kept navigating back into the tabs as if /(tabs) was just another ordinary route.

That is where the duplication happened.

I was not always returning to the existing tabs branch.

Sometimes I was effectively creating a fresh one.

In other words, the app already had one tabs shell alive in the root stack, and I was accidentally mounting another tabs shell that happened to render the same home UI.

The easiest way to duplicate your tabs

Once you have a mixed graph, these kinds of actions become dangerous:

router.replace("/(tabs)/(home)");
router.dismissTo("/");

They look harmless, but they are not the same thing as "return to the existing tabs instance".

What I wanted was this:

root stack
├─ (tabs)            // existing tabs instance
├─ details/[id]
├─ processing/[id]
└─ result/[id]

back action -> return to the existing (tabs) branch

What I was sometimes doing instead was this:

root stack
├─ (tabs)            // original tabs instance still exists
├─ details/[id]
├─ processing/[id]
├─ result/[id]
└─ (tabs)            // fresh tabs instance mounted again

That second case is the real bug.

It means the user sees a home screen and a tab bar, but they are no longer inside the tab branch they built up before.

In practice, that showed up as:

  • mounting a second tabs branch on top of the original one
  • rebuilding the tabs branch instead of returning to the existing one
  • losing the current tab state
  • losing scroll position or loaded tab state
  • making the navigation history feel inconsistent

The nasty part is that this bug is easy to miss.

You still see a home screen.

You still see a tab bar.

But now you are sitting inside a fresh tabs instance instead of the one the user had already built up.

That is why tab state feels randomly broken.

Be precise about what "home" actually means

After that, I stopped treating "go home" as a vague idea.

I made it explicit:

export const TAB_HOME_ROUTE = "/(tabs)/(home)";
export const TAB_CREATE_ROUTE = "/(tabs)/(create)";
export const TAB_PROFILE_ROUTE = "/(tabs)/(profile)";

And when I needed to go back to tabs, I targeted the real tab route:

router.dismissTo("/(tabs)/(home)");

Not:

router.dismissTo("/");
router.replace("/(tabs)/(home)");

I ended up leaning on dismissTo for this, but the important part was not the API itself.

The important part was that the destination stopped being vague.

That was the first real fix.

Once the target is precise, the navigation stops being "best effort" and starts matching the actual graph.

The symptom was not "wrong screen", it was "wrong instance"

That distinction matters.

The user was often still looking at the correct tab.

The problem was that it was a new tab tree.

That is why the breakage showed up indirectly:

  • scroll position was gone
  • previously loaded tab state was gone
  • the app felt like it forgot where the user had been
  • repeated flows made the stack feel stranger over time

Once I started thinking in terms of "am I returning to the existing tabs instance, or creating a new one?" the whole issue became much easier to reason about.

Keep dynamic return targets on a short leash

I also had flows that carried a returnTo param around.

That part can get messy fast.

If you let any random value decide where the app should go next, you end up with navigation that is both hard to reason about and easy to break.

So the rule I settled on was simple:

  • keep return targets very limited
  • fall back to a real tab route when the target is missing or invalid
  • do not let "return to where you came from" recreate a different branch of the app

That part is less exciting than the duplicated-tabs bug, but it supports the same goal:

get the user back to the existing navigation branch, not a fresh copy of something that only looks right.

What actually fixed it

If I had to compress this into a few rules, it would be these:

  • do not treat /(tabs) like a generic home shortcut
  • use the exact tab route you want, like /(tabs)/(home)
  • think in terms of "existing tabs instance" instead of "some home route"
  • make dynamic return targets pass through a small whitelist
  • optimize for re-entering the current tabs branch, not recreating a new one

Final thoughts

The main mistake was not understanding the graph well enough.

Once I accepted that tabs were just one branch inside a bigger root stack, the navigation rules got much simpler.

And to give Expo Router some credit, the fix was not some ugly workaround.

The hard part was just being honest about what the app graph actually was.

The tabs were not the app.

They were one subtree inside the app.

Once I stopped navigating to "home" loosely and started returning to the exact existing tabs branch, the duplicated-tabs problem went away.

If your app has a mixed root stack plus tabs graph, that is where I would start.