DEV Community

Arshan Nawaz
Arshan Nawaz

Posted on

Building Instant, Correct Retained Root Tabs in React Native

Building Instant, Correct Retained Root Tabs in React Native

Custom bottom tabs look simple until three requirements arrive together:

  1. Touch feedback must appear in one frame.
  2. Previously visited tabs must preserve navigation and scroll state.
  3. Rapid taps must always finish on the user's final target.

On a busy Android application, a naive implementation can violate all three. The bottom bar highlights one tab while another tab's content remains visible. Rapid taps replay old destinations in sequence. A first visit briefly shows a blank screen. Adding delays, responder overrides, loading placeholders, or more state variables hides individual symptoms without removing the ownership conflict.

This article describes a durable architecture for custom expo-router/ui tabs using React Native, React Navigation, Reanimated, and retained tab trees.

The Failure Pattern

A common custom-tab implementation has two independent systems:

  • A Reanimated shared value updates the selected icon on the UI thread.
  • React Navigation processes JUMP_TO on the JavaScript thread and changes the focused native screen later.

Under light load, both systems appear synchronized. Under heavy rendering, map work, large lists, or slow JavaScript tasks, their timing separates:

Touch begins
  -> UI-thread indicator selects Home immediately
  -> JavaScript callback waits
  -> old Record route remains focused in the native screen container
  -> screen shows Home selected with Record content
  -> delayed route commits replay older taps
Enter fullscreen mode Exit fullscreen mode

The root problem is not touch handling. It is conflicting ownership:

  • the custom chrome owns visual selection;
  • the retained-pane layer tries to own visible content;
  • the native screen container still owns route stacking;
  • React render closures contain an older route name;
  • the scheduler only knows callbacks that have already reached JavaScript.

When several authorities can decide what the user sees, they eventually disagree.

Architectural Rule

The displayed pane and selected tab chrome must share one visual authority. The router is an asynchronous synchronization target, not the immediate display owner.

Use these state roles:

State Owner Purpose
requestedIndex UI thread Latest tab requested by the user; also prepares an unmounted first-visit pane.
visibleIndex UI thread The one pane currently displayed and the one tab painted as selected.
readyMask UI thread Bounded bitmask identifying panes that have completed native layout.
intentEpoch UI thread Monotonic token for ordering physical touch intents before JavaScript receives them.
committedRoute JavaScript coordinator Last route actually acknowledged by the router.
latestTarget JavaScript coordinator Final accepted UI target whose route synchronization is still pending.

requestedIndex and visibleIndex are intentionally different only during a first visit. The requested pane can mount invisibly while the previous pane remains visible. Once layout is ready, visibleIndex changes atomically. Because the bottom bar also paints from visibleIndex, it cannot claim that content has switched before it actually has.

Remove Native Root-Screen Competition

TabSlot normally renders routes in a native ScreenContainer. That is useful when route focus is the display authority. It conflicts with custom UI-thread pane switching because the native container can keep its focused route above the pane selected by Reanimated.

At the custom retained-root boundary:

<TabSlot
  detachInactiveScreens={false}
  renderFn={renderRetainedRootPane}
/>
Enter fullscreen mode Exit fullscreen mode

Render every visited root as an absolute sibling Animated.View, not as another root-level native Screen:

function RetainedRootPane({ index, children }: Props) {
  const requestedIndex = useRequestedIndex();
  const visibleIndex = useVisibleIndex();
  const readyMask = useReadyMask();

  const style = useAnimatedStyle(() => {
    const visible = visibleIndex.value === index;
    const preparing = requestedIndex.value === index;

    return {
      display: visible || preparing ? 'flex' : 'none',
      opacity: visible ? 1 : 0,
      zIndex: visible ? 1 : 0,
    };
  });

  const animatedProps = useAnimatedProps(() => ({
    pointerEvents: visibleIndex.value === index ? 'box-none' : 'none',
  }));

  const markReady = () => {
    const bit = 1 << index;
    readyMask.value |= bit;

    if (requestedIndex.value === index) {
      visibleIndex.value = index;
    }
  };

  return (
    <Animated.View
      animatedProps={animatedProps}
      onLayout={markReady}
      style={[StyleSheet.absoluteFill, style]}
    >
      {children}
    </Animated.View>
  );
}
Enter fullscreen mode Exit fullscreen mode

This does not remove native screens from nested stacks inside each tab. It removes native screen competition only at the custom root-pane layer. Nested route stacks continue to own their own navigation normally.

Paint Chrome From the Displayed Pane

The bottom bar must not paint selection from a separate optimistic variable:

const selectedStyle = useAnimatedStyle(() => ({
  opacity: visibleIndex.value === index ? 1 : 0,
}));
Enter fullscreen mode Exit fullscreen mode

For a warm tab, touch begin updates both requested and visible state in the same UI-thread worklet:

const tap = Gesture.Tap()
  .onBegin(() => {
    const nextIntent = intentEpoch.value + 1;
    intentEpoch.value = nextIntent;
    gestureIntentId.value = nextIntent;
    previousVisibleIndex.value = visibleIndex.value;

    requestedIndex.value = index;
    if ((readyMask.value & (1 << index)) !== 0) {
      visibleIndex.value = index;
    }
  })
  .onEnd((_event, success) => {
    if (success) {
      runOnJS(commitIntent)(gestureIntentId.value);
    }
  })
  .onFinalize((_event, success) => {
    if (!success && gestureIntentId.value === intentEpoch.value) {
      requestedIndex.value = previousVisibleIndex.value;
      visibleIndex.value = previousVisibleIndex.value;
      runOnJS(cancelIntent)(gestureIntentId.value);
    }
  });
Enter fullscreen mode Exit fullscreen mode

Restoring previousVisibleIndex is important. Restoring a route name captured by React can reintroduce stale state during a cancelled gesture.

Treat Navigation as Ordered Synchronization

React Navigation still needs the final route for URLs, nested stacks, back behavior, lifecycle, and accessibility semantics. It simply does not own the immediate visual switch.

The coordinator needs more than a zero-delay microtask:

  • reject callbacks older than the latest UI-thread epoch;
  • coalesce callbacks delivered in the same JavaScript turn;
  • avoid duplicate dispatch to the same outstanding target;
  • preserve a final return to the currently committed route;
  • distinguish repeated destinations such as A -> B -> A;
  • ignore intermediate route commits superseded by a newer target;
  • release synchronization after a cancelled gesture;
  • track the acknowledged route internally instead of trusting a stale render closure.

The request contract carries both the callback token and the latest epoch currently visible to JavaScript:

type SwitchRequest<Tab> = {
  target: Tab;
  renderRoute: Tab;
  intentId: number;
  latestUiIntentId: number;
  navigate: (target: Tab) => void;
};
Enter fullscreen mode Exit fullscreen mode

Reject a delayed callback when a newer physical touch has already happened:

if (request.intentId !== request.latestUiIntentId) return false;
if (request.intentId <= latestAcceptedIntentId) return false;
Enter fullscreen mode Exit fullscreen mode

Do not use renderRoute as the normal same-route authority. It is only an initialization fallback:

const currentRoute = committedRoute ?? request.renderRoute;
Enter fullscreen mode Exit fullscreen mode

This matters because a component can still hold renderRoute = Profile after the router has committed Community. Dropping a new Profile request as a same-tab press would leave the router on Community while the retained pane displays Profile.

Maintain an ordered list of dispatched targets. A repeated destination is not automatically the final destination:

Dispatched: Record, Community, Record
Committed:  Record
Remaining:  Community, Record
Enter fullscreen mode Exit fullscreen mode

The first Record commit must not clear the final Record target. Only the final matching commit, with no later matching dispatch still outstanding, completes synchronization.

Route Commit Acknowledgment

Every router commit updates the coordinator's canonical route:

function acknowledgeRoute<Tab>(
  route: Tab,
  latestUiIntentId: number,
): boolean {
  committedRoute = route;
  removeCommittedPrefix(route);

  if (latestUiIntentId > latestAcceptedIntentId) return false;
  if (latestTarget == null) return true;
  if (latestTarget !== route) return false;
  if (outstandingTargets.includes(route)) return false;

  latestTarget = null;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The UI epoch check closes a subtle race:

  1. Record navigation was dispatched.
  2. The user touched Home on the UI thread.
  3. Home's runOnJS callback has not executed yet.
  4. Record route commits on JavaScript.

The scheduler has not accepted Home yet, but the shared UI epoch proves a newer intent exists. The Record commit must not overwrite the displayed Home pane.

When no UI intent is outstanding, route acknowledgment returns true. This allows external navigation, deep links, authentication redirects, and normal router-driven changes to synchronize the displayed root.

First-Visit Atomicity

A first visit is different from a warm switch because no retained tree exists yet.

Correct sequence:

Touch target
  -> requestedIndex changes
  -> previous visibleIndex remains displayed
  -> router loads target descriptor
  -> target pane mounts with opacity 0
  -> target onLayout marks ready
  -> visibleIndex switches to target
  -> content and bottom chrome change together
Enter fullscreen mode Exit fullscreen mode

Do not insert a generic fallback screen between these states. Real loading UI belongs to the target screen and should appear only when that screen has no cached content.

Retention and Dormancy

Retaining every root does not mean every root should remain active.

Keep the retained set permanently bounded by the known root tabs. Use route lifecycle separately to deactivate hidden work:

  • unsubscribe hidden query observers;
  • pause screen-owned timers and media;
  • detach screen-owned realtime listeners;
  • disable hidden-pane pointer events;
  • retain cached data, nested route state, local row state, and scroll position.

Global authentication, bootstrap ownership, push registration, and application-level realtime bridges should remain outside tab lifecycle.

Visual ownership and lifecycle ownership serve different purposes:

  • visibleIndex decides what the user sees immediately;
  • committed route focus decides which tab's background work is active after navigation catches up.

Approaches That Do Not Solve the Root Cause

Arbitrary debounce delays

A timeout can collapse some taps, but it adds latency and cannot cancel a navigation already dispatched before a later touch reaches JavaScript.

Same-turn microtask coalescing alone

It only combines callbacks delivered in one JavaScript turn. Under load, native touch callbacks arrive in separate turns, so stale destinations still replay.

Optimistic icon with route-owned content

This directly creates selected-icon/content mismatch. Fast chrome is not useful when it lies about the displayed screen.

Route effects that always overwrite UI state

Intermediate route commits can replace a newer UI-thread selection. Route effects must pass through intent acknowledgment.

Comparing against a route prop captured by React

The prop can be older than the router's committed state. The coordinator must maintain its own acknowledged route.

Native Screen wrappers inside a custom retained root slot

They restore native route stacking as a second display owner. Keep native stacks inside each tab, not around custom root panes controlled by another visibility system.

Loading placeholders used to cover blank frames

Replacing a blank frame with an unrelated skeleton changes the symptom, not the ownership conflict. First-visit readiness and real target loading state should determine what is shown.

Responder overrides and touch interception changes

When the wrong route is visible, changing pointerEvents, adding overlays, or replacing press components does not repair route ordering or screen stacking.

Required Regression Tests

Coordinator unit tests should cover:

  1. Same-turn burst dispatches only the final target.
  2. Older and duplicate intent tokens are rejected.
  3. A callback superseded by a newer UI epoch is dropped.
  4. Pressing the committed tab is a navigation no-op.
  5. Repeated taps on one outstanding target dispatch once.
  6. Returning to the original route while another route is outstanding dispatches the return.
  7. A -> B -> A does not mistake the first A commit for the final A.
  8. A route commit is ignored while a newer UI callback is pending.
  9. Cancelled gestures release future external route synchronization.
  10. The acknowledged route overrides a stale render closure.
  11. External route changes work when no UI intent is outstanding.

Retained-pane component tests should cover:

  1. Root panes are plain retained siblings, not nested native root screens.
  2. Hidden panes receive no pointer events.
  3. A target can prepare invisibly without replacing the current pane.
  4. onLayout switches only when that pane is still the requested target.
  5. A late layout from an abandoned first visit cannot steal visibility.
  6. Every visited root remains retained within the bounded set.
  7. Route lifecycle deactivation does not remove cached children.

Physical Android Verification

Automated tests cannot reproduce Android input delivery, JavaScript stalls, native view stacking, and map/list workload together. Test a development build for iteration, then repeat acceptance on a release build without inspector overhead.

Warm every root once, then verify:

  • five repeated taps on the selected tab;
  • ten rapid taps ending on Home;
  • ten rapid taps ending on a map or otherwise heavy tab;
  • A -> B -> A with delayed commits;
  • returning to the route that was current when the burst began;
  • a cancelled drag beginning on a tab item;
  • rapid switching while network responses arrive;
  • rapid switching while a heavy list is rendering;
  • background/foreground recovery;
  • nested-route return inside each retained root.

For every case, compare in the same observation:

selected bottom item == visible retained pane == final committed route
Enter fullscreen mode Exit fullscreen mode

For cold first visits, verify the stricter invariant:

no blank frame
and
selected bottom item == currently visible pane
Enter fullscreen mode Exit fullscreen mode

The route can finish loading asynchronously, but the chrome must never claim that an undisplayed pane is already visible.

Performance Instrumentation

Record separate marks for:

  • physical intent time;
  • router navigation commit;
  • first paint after commit.

Do not combine these into one number. The visual pane can switch within one frame even if router synchronization takes longer. Separate measurements reveal whether latency comes from input delivery, JavaScript scheduling, route reconciliation, or target rendering.

Useful release-build budgets are:

  • touch-to-visual feedback p95 at or below two frames;
  • warm retained-pane switch p95 below 100 ms;
  • no navigation-owned JavaScript task above 100 ms;
  • no continuing hidden-tab renders after route lifecycle settles;
  • bounded memory after every root has been visited.

Review Checklist

Before merging any future root-tab change, confirm:

  • [ ] Bottom chrome paints from the same shared value that displays content.
  • [ ] Root retained panes are not competing native screens.
  • [ ] Native stacks remain scoped inside individual roots.
  • [ ] Router commits pass through the intent coordinator.
  • [ ] Same-route checks use the acknowledged route, not a render closure.
  • [ ] UI epoch is checked both when accepting callbacks and acknowledging commits.
  • [ ] Cancelled gestures notify the coordinator.
  • [ ] Repeated outstanding targets do not dispatch duplicates.
  • [ ] A -> B -> A is covered by tests.
  • [ ] First-visit layout cannot steal visibility after a newer request.
  • [ ] Hidden panes cannot receive touches.
  • [ ] Real loading UI is owned by the target screen.
  • [ ] No arbitrary delay, fallback pane, responder override, or loading mask is presented as the fix.
  • [ ] Physical Android rapid switching passes after a clean reload.

Closing Principle

Instant tabs are not primarily an animation problem. They are an ownership and ordering problem.

Give one UI-thread state authority over both visible content and selected chrome. Keep the router as the canonical navigation record, synchronized through an intent-aware coordinator. Retain bounded root trees without allowing native root-screen stacking to compete with custom pane visibility. Once those responsibilities are explicit, rapid taps stop being a collection of timing patches and become a deterministic state transition system.

Top comments (0)