Skip to content

[BUG] useTransform value desyncs when source is set inside an effect under React 18 Strict Mode #3769

Description

@jindong-zhannng

Summary

Updating a MotionValue inside a (layout) effect during Strict Mode's mount→unmount→remount cycle leaves a useTransform-derived value permanently stuck at its stale value.

Root cause

useCombineMotionValues recomputes via two paths: a sync updateValue() in the render body, and an async frame.preRender(updateValue) on the source's change, cancelled by cancelFrame in the effect cleanup.

const updateValue = () => value.set(combineValues())
/**
* Synchronously update the motion value with the latest values during the render.
* This ensures that within a React render, the styles applied to the DOM are up-to-date.
*/
updateValue()
/**
* Subscribe to all motion values found within the template. Whenever any of them change,
* schedule an update.
*/
useIsomorphicLayoutEffect(() => {
const scheduleUpdate = () => frame.preRender(updateValue, false, true)
const subscriptions = values.map((v) => v.on("change", scheduleUpdate))
return () => {
subscriptions.forEach((unsubscribe) => unsubscribe())
cancelFrame(updateValue)
}
})

Failing sequence under Strict Mode:

  1. First effect sets source → changeupdateValue scheduled to next frame (still stale).
  2. Strict Mode cleanup runs cancelFrame → pending task cancelled before it flushes (remount is continuous, no frame in between).
  3. Remount re-subscribes but doesn't re-render, so the sync fallback never runs again.
  4. Effect sets the same valueset short-circuits → no change → nothing re-scheduled.

Result: derived value stays at f(oldValue) while source is newValue.

React 19 behavior changes

This bug disappears in React 19:

In React 18, Strict Mode's mount→unmount→remount runs synchronously inside the commit phase, so the whole effect → cleanup → effect sequence finishes before any frame. Derived value's update task is canceled and never runs.

In React 19, the double-invoke was moved into the passive-effects phase. The order becomes effect → rAF → cleanup → effect. The derived value update task can run before cleanup so the bug disappears.

Reproduction

https://codesandbox.io/p/sandbox/snowy-river-qq5y9c

Expected vs actual

Expected: doubled equals 20. Actual: doubled equals 0, never recovers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions