I park a while(true) on the JavaScript thread of a React Native app. The thread is dead. I wrote the animation in JavaScript. The Reanimated gesture keeps tracking my finger at 60fps.
The obvious read: the animation left JavaScript for native code. It did not. Your app runs two JavaScript runtimes, and the second one lives on the UI thread.
The second runtime
Krzysztof Magiera set the design in Introducing Reanimated 2. Reanimated 1 shipped a declarative animation DSL. The native driver shipped whitelists. Reanimated 2 spawns a secondary JS context on the UI thread and runs real JavaScript. Functions marked as worklets execute there:
const style = useAnimatedStyle(() => {
'worklet'
return { transform: [{ translateX: offset.value }] }
})The 'worklet' directive is a compiler hook. The babel plugin finds these functions and rewrites each into a factory. The 2.x line ships roughly this:
const _f = function () {
return { transform: [{ translateX: offset.value }] }
}
_f._closure = { offset } // captured variables, by name
_f.asString = 'function _f(){...}' // source, re-evaluated on the UI runtime
_f.__workletHash = 1234567 // identity across runtimes
_f.__location = '/app/Box.js (12:4)'A worklet is code-as-string plus a captured-value bag, rebuilt inside the second runtime. People conflate two phases. The plugin extracts the closure's identifier list at build time. The runtime copies the values at instantiation. That copy is the root of every gotcha below.
You rarely write the directive. The plugin auto-workletizes useAnimatedStyle, useDerivedValue and Gesture callbacks. Check global._WORKLET === true to know where you are.
Shared values, the actual bridge
Two runtimes need shared memory. Shared values are that memory: C++-backed host objects, reachable from both runtimes over JSI, no serialization on the hot path. The classic RN bridge is async, batched and JSON. It never enters the picture. That is the whole 60fps secret. Not faster JavaScript. Closer JavaScript.
const offset = useSharedValue(0)
const pan = Gesture.Pan()
.onChange((e) => {
// runs on the UI runtime
offset.value += e.changeX
})
.onEnd(() => {
offset.value = withSpring(0)
})Assign to .value from a worklet and the animated style reacts on the same frame. No setState, no render, no reconciliation. React never wakes up. My while(true) still spins. The box still follows my finger.
The gotchas, and they bite
- Closures are copied, not referenced. Mutate a captured plain object from the React side and the worklet never sees it. It holds its own copy. Shared values exist precisely to opt out of copy semantics.
- Big captures cost real money. Everything in
_closureserializes at worklet instantiation. Capture a 2.000-item array in a gesture callback and you built a frame-time bomb. Keep captures scalar. - Crossing back is explicit. A regular function does not exist on the UI runtime. Call it from a worklet and it throws.
runOnJS(fn)(args)schedules it back on the React runtime, asynchronously, by handle. Sofnmust be a stable top-level reference, and you cannot await its result mid-gesture:
.onEnd(() => {
runOnJS(onGestureFinished)()
})console.loginside a worklet works. It is arunOnJSin a trenchcoat. Log per frame and you profile the mystery jank you just created.
The dogma had one JavaScript thread over an async bridge. Worklets break it quietly. Run the latency-critical 5% of your JavaScript on the UI thread. Keep React for the other 95%. The second runtime is here. Learn its physics now.