Skip to content

fix(curved-bottom-tabs): keep the hook order stable when tabs change - #49

Open
dennytosp wants to merge 1 commit into
rit3zh:mainfrom
dennytosp:fix/curved-bottom-tabs-hooks-in-loop
Open

fix(curved-bottom-tabs): keep the hook order stable when tabs change#49
dennytosp wants to merge 1 commit into
rit3zh:mainfrom
dennytosp:fix/curved-bottom-tabs-hooks-in-loop

Conversation

@dennytosp

Copy link
Copy Markdown

Follow-up to #48, same class of bug in a second component.

Problem

CurvedBottomTabs breaks permanently as soon as the number of tabs changes — adding or removing a route, or rendering a different set of tabs per screen. React logs "React has detected a change in the order of Hooks" and the tab bar renders nothing from that point on.

Cause

Two hooks ran once per tab, from inside loop callbacks:

const floatingAnimations = useRef<SharedValue<number>[]>(
  tabs.map(() => useSharedValue<number>(0)),   // L224
).current;

// ...inside the render body
{tabs.map((tab, index) => {
  const animatedTabStyle = useAnimatedStyle<ViewStyle>(() => ({   // L311
    transform: [{ translateY: floatingAnimations[index].value }],
  }));

The number of hooks the component ran was therefore 1 + 2 × tabs.length.

The useRef wrapper does not help, and in fact hides a second bug: useRef's argument is evaluated on every render, so useSharedValue still ran once per tab each time — while .current stayed pinned to the array built during the first render.

Fix

Move the float animation into a TabItem child component that owns its own shared value and animated style and reacts to an isActive prop. Each instance runs a fixed number of hooks, so React mounts and unmounts tabs normally. animateToIndex now only drives the background curve.

The animation is unchanged: same spring config, same -VIEWPORT_HEIGHT * 4.2 target for the active tab. No public API change.

Verification

Rendered the real component before and after (native modules stubbed so the stubs map onto real React hooks), driving it through a tab switch and then two changes of tab count:

3 tabs switch active tab → 5 tabs → 2 tabs hook-order warnings crashes
before 3 3 0 0 1 2
after 3 3 5 2 0 0

To confirm the refactor is behaviour-preserving, I also captured every shared value after a render with 3 tabs at currentIndex: 2 — the two versions are identical:

before: [-260, 0, 0, -35.448]     // curvePosition, tab0, tab1, tab2
after:  [-260, 0, 0, -35.448]

tsc --noEmit reports the same error count as main (none in this file).

website/react-native/curved-bottom-tabs.tsx is regenerated via bun run sync:changed.

`CurvedBottomTabsCore` ran two hooks per tab from inside loop callbacks —
`useSharedValue` via `useRef(tabs.map(...))` and `useAnimatedStyle` inside
the render-body `tabs.map(...)`. The number of hooks the component ran was
therefore driven by `tabs.length`, so adding or removing a route changed
the hook order and React tore the tab bar down.

Wrapping the shared values in `useRef` did not help: the argument is
evaluated on every render, so `useSharedValue` still ran once per tab each
time, while `.current` stayed pinned to the array built on the first
render.

Move the float animation into a `TabItem` child that owns its own shared
value and animated style and reacts to an `isActive` prop, so each
instance runs a fixed number of hooks and React can mount and unmount tabs
normally. `animateToIndex` now only drives the background curve.

The animation itself is unchanged — same spring config, same
`-VIEWPORT_HEIGHT * 4.2` target for the active tab.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant