Skip to content

fix(pulsing-dots): keep the hook order stable when dotCount changes - #48

Open
dennytosp wants to merge 1 commit into
rit3zh:mainfrom
dennytosp:fix/pulsing-dots-hooks-in-loop
Open

fix(pulsing-dots): keep the hook order stable when dotCount changes#48
dennytosp wants to merge 1 commit into
rit3zh:mainfrom
dennytosp:fix/pulsing-dots-hooks-in-loop

Conversation

@dennytosp

Copy link
Copy Markdown

Found while reading through the component library — not tied to an existing issue.

Problem

PulsingDots breaks permanently the moment dotCount changes at runtime. React logs "React has detected a change in the order of Hooks called by PulsingDots" and the component renders nothing from that point on.

Cause

Both animation hooks were called from inside loop callbacks, so the number of hooks the component ran was driven by a prop:

const opacities = Array.from({ length: dotCount }, () => useSharedValue(0.3));
// ...
const animatedProps = opacities.map((val) => useAnimatedProps(() => ({ opacity: val.value })));

Two smaller problems sat alongside it:

  • the staggered start used setTimeout with no cleanup, so an unmounted component could still be scheduled;
  • the effect's dependency array was [], so changing duration after the first render had no effect.

Fix

Move the per-dot animation into a Dot child component. Each instance runs a fixed number of hooks, so React mounts and unmounts dots normally as dotCount changes.

The stagger now uses Reanimated's withDelay instead of setTimeout — no uncancelled timer, and the animation is torn down with the component — and the effect tracks duration.

No public API change: the same props render the same output.

Verification

Rendered the real component before and after (native modules stubbed so the stubs map onto real React hooks), mounting at dotCount=3 and then changing it to 5 and 2:

3 → 5 → 2 → hook-order warnings crashes
before 3 dots 0 dots 0 dots 1 2
after 3 dots 5 dots 2 dots 0 0

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

website/react-native/pulsing-dots.tsx is regenerated via bun run sync:changed.


While looking at this, src/components/base/curved-bottom-tabs/index.tsx has the same class of problem — useSharedValue inside useRef(tabs.map(...)) (L224) and useAnimatedStyle inside tabs.map(...) (L311). It needs a larger refactor, so I have left it out of this PR; happy to open a separate one if you'd like.

`PulsingDots` called `useSharedValue` and `useAnimatedProps` once per dot
from inside `Array.from`/`map` callbacks, so the number of hooks the
component ran was driven by the `dotCount` prop. Changing `dotCount` at
runtime changed the hook order and React tore the component down —
it rendered nothing from that point on.

Move the per-dot animation into a `Dot` child component so each instance
runs a fixed number of hooks and React can mount and unmount dots
normally. The staggered start now uses `withDelay` instead of an
uncancelled `setTimeout`, which also stops the animation from being
scheduled after unmount, and the effect tracks `duration` so the prop is
no longer ignored after the first render.
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