Worklets, shared values and the UI thread explained
Reanimated 3 moves animations off the JS thread and onto the UI thread, keeping React Native apps fluid even under load. This article explains worklets, useSharedValue, useAnimatedStyle, runOnJS and runOnUI, the motion functions withTiming, withSpring and withDecay, plus layout animations and interpolate(), with concrete code examples.
Table of Contents
- 1. Why UI thread animations matter
- 2. Worklets and the UI thread execution model
- 3. useSharedValue: reactive state across thread boundaries
- 4. useAnimatedStyle: declarative styles from shared values
- 5. runOnJS and runOnUI: crossing threads safely
- 6. withTiming, withSpring and withDecay: choosing motion curves
- 7. Layout animations: entering, exiting and automatic transitions
- 8. interpolate(): mapping scroll and gesture values to visual properties
- 9. Reanimated 3 versus the old Animated API
- 10. Summary
- 11. FAQ
1. Why UI thread animations matter
React Native has always split execution across multiple threads: the JS thread runs the actual application logic, render cycles, network callbacks and state updates, while the UI thread (the main thread on iOS, the UI thread of the operating system on Android) is responsible for actually drawing the native views. The classic Animated API in React Native could offload part of an animation to the UI thread with useNativeDriver: true, but it was limited to a narrow set of properties, essentially transform and opacity. As soon as an animation depended on state living on the JS thread, for example a gesture processed while a network request was in flight, a value had to be sent across the bridge on every single frame. That costs time, and that time is missing for hitting 60 frames per second.
Reanimated 3 solves this problem in a fundamentally different way: animation logic runs as a so-called worklet directly on the UI thread, decoupled from whatever the JS thread happens to be doing. When an app parses a large JSON response in the background, re-renders a complex React tree, or runs an expensive calculation, that blocks the JS thread, not the UI thread. An animation that runs entirely through Reanimated 3 stays fluid in exactly that moment, while an animation depending on the JS thread stutters or freezes completely. This is the core reason Reanimated 3 has become the de facto standard for animations in production React Native apps.
Technically, this is made possible by JSI (JavaScript Interface), available since React Native 0.66 and further expanded with the new architecture (Fabric, TurboModules). Instead of routing every bit of communication between the JS thread and the native side through the asynchronous, serializing bridge, JSI allows synchronous, direct function calls between JavaScript and C++. Reanimated 3 builds exactly on top of this: a worklet is compiled once for the UI thread and then runs there independently, with no bridge round trip required for every single animation step. That is the difference between animations that stay clean at 60 or even 120 hertz, and animations that visibly stutter under load.
2. Worklets and the UI thread execution model
A worklet is a JavaScript function marked with the 'worklet' directive, which the Reanimated 3 Babel plugin treats specially. The plugin extracts the function body at build time, serializes it into a form that can also run on the UI thread, and attaches a reference to this compiled version to the function. At runtime, Reanimated 3 can then execute this compiled representation directly on the UI thread, regardless of what the JS thread is currently doing. Importantly, a worklet runs in its own, minimal JavaScript context on the UI thread, not in the same context as the rest of the app logic. This explains why closures over external variables do work but are copied rather than referenced, and why modules like Date or large libraries have only limited usability inside a worklet.
In practice, you rarely need to write 'worklet' explicitly, because many Reanimated 3 APIs like useAnimatedStyle or useDerivedValue automatically treat their callback function as a worklet. Still, this concept is the foundation of everything covered in this article: useSharedValue, useAnimatedStyle, withTiming and withSpring only work because worklets run on the UI thread behind the scenes and have direct access to native view properties there. For this mechanism to work at all, the Reanimated 3 Babel plugin must be wired up correctly, and specifically as the last plugin in the list, because it analyzes the source code after all other transformations have run.
# Install Reanimated 3 (works with React Native 0.73 and newer)
npm install react-native-reanimated
# iOS only: install the native pods for the new architecture
cd ios && pod install && cd ..
# Clear the Metro cache after adding the Babel plugin below
npx react-native start --reset-cache
{
"presets": ["module:metro-react-native-babel-preset"],
"plugins": [
"react-native-reanimated/plugin"
]
}
The Reanimated 3 Babel plugin entry must always be the last element in the plugins array of babel.config.js. If it is missing or placed incorrectly, worklets will not be compiled correctly, and Reanimated 3 throws cryptic runtime errors about missing functions on the UI thread. After any change to the Babel config, a full restart with a cleared Metro cache is required, a simple hot reload is not enough because the worklet transformation only kicks in during the initial bundling step.
3. useSharedValue: reactive state across thread boundaries
useSharedValue is the central building block of Reanimated 3 and replaces the classic useState hook in animation contexts. A shared value is an object with exactly one property, .value, that can be read and written from both the JS thread and the UI thread without triggering a React re-render. That is the decisive difference from useState: a state update in React always triggers a render cycle of the component, which would be completely unsuitable for 60 updates per second. A shared value, on the other hand, can change as often as needed per second without React noticing it at all.
When the value of a shared value is changed on the UI thread, for example through withSpring or directly through a gesture, every dependent useAnimatedStyle call reacts immediately and also on the UI thread, without the detour through the JS thread. This is the mechanism that makes Reanimated 3 so performant: a chain of shared value, motion function and animated style stays entirely on the UI thread and is therefore independent of JS thread load. A shared value is always created with an initial value and stays stable across the entire lifetime of the component, similar to a ref, except it can additionally notify the UI thread of changes.
A common misunderstanding: you never read the current value of a shared value directly inside JSX or in normal render code, only inside a worklet, for example within useAnimatedStyle or useDerivedValue. Accessing sharedValue.value directly in the render function works syntactically, but it only returns the value at the time of the last render and does not automatically update with every animation step, precisely because Reanimated 3 deliberately does not trigger a re-render.
4. useAnimatedStyle: declarative styles from shared values
useAnimatedStyle is itself a worklet and Reanimated 3 automatically re-evaluates it whenever any of the shared values read inside it change. The result is a style object that is passed directly to an Animated.View, Animated.Text, or another component exported by Reanimated 3. This evaluation happens entirely on the UI thread, in exactly the frame in which the shared value changes, without a single bridge call and without the React component itself re-rendering. To the developer, this feels like normal React, but at runtime it is a fundamentally different, much faster mechanism.
The dependency detection of useAnimatedStyle works automatically through static analysis of the callback code, there is no manual dependency array like with useEffect. Every shared value read via .value inside the callback is automatically registered as a dependency. The following example shows a typical fade-in effect, where opacity and a slight upward shift are both derived from the same shared value.
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
function FadeInBox({ children }) {
// Shared value lives on the UI thread, mutating it never triggers a re-render
const opacity = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
// This callback is a worklet: it runs on the UI thread, not the JS thread
return {
opacity: opacity.value,
transform: [{ translateY: (1 - opacity.value) * 20 }],
};
});
React.useEffect(() => {
opacity.value = withTiming(1, { duration: 400 });
}, []);
return <Animated.View style={[styles.box, animatedStyle]}>{children}</Animated.View>;
}
It matters that the individual style properties are computed inside the callback, rather than pre-computed outside and merely plugged in. Reanimated 3 analyzes exactly the code inside the function to detect which shared values are referenced. If an intermediate value is computed outside the function and only the result is passed in, Reanimated 3 loses reactivity, and the animation freezes at the first rendered value.
5. runOnJS and runOnUI: crossing threads safely
Worklets run in their own context on the UI thread and therefore have no direct access to normal JavaScript functions defined in the JS thread context, such as setState, navigation calls, or API calls. This is exactly what runOnJS is for: it takes a normal JS function and schedules its execution on the JS thread, called from within a worklet. A typical use case is a gesture that is handled entirely on the UI thread but should trigger a state update on the JS thread at the end, for example when released, to remove a card from a list or fire an analytics event.
The reverse case is covered by runOnUI: from the JS thread, a worklet is explicitly scheduled to run on the UI thread. This is needed less often, because most Reanimated 3 hooks handle it automatically, but it becomes relevant when you want to update several shared values in a single, synchronous block on the UI thread to avoid visible intermediate states. One example is resetting position, scale and opacity simultaneously at the end of a complex gesture interaction, without a visible intermediate frame appearing between the individual assignments.
A common performance mistake is overusing runOnJS: if a function is dispatched to the JS thread on every single animation frame, for example to update a progress indicator as text, the exact bridge ping pong that Reanimated 3 is meant to avoid reappears. Instead of calling runOnJS on every frame, the target value should, where possible, be kept as a shared value directly, with the visual representation derived through useAnimatedStyle or useAnimatedProps, rather than going through React state and re-renders.
6. withTiming, withSpring and withDecay: choosing motion curves
Reanimated 3 provides three central motion functions, each producing a different character of animation. withTiming animates a shared value over a fixed duration with a definable easing curve, for example Easing.out(Easing.cubic), and is suited to transitions that should have a predictable, even time frame, such as fading a modal in and out. withSpring, on the other hand, is based on a physical spring model with the parameters damping, stiffness and mass, instead of a fixed duration. The result feels more organic and reacts more naturally to interaction, because the animation does not end abruptly after a fixed time, but settles into a resting position.
withDecay covers a third case: motion based on an initial velocity that is decelerated over time, classically after a fling gesture during scrolling or swiping. Instead of specifying a target value, you pass velocity and optionally clamp to limit the allowed value range, and Reanimated 3 calculates the deceleration curve from that. All three functions return an animatable value themselves, which is assigned to a shared value, and all three accept an optional callback as a second or third argument that fires when the animation completes, typically in combination with runOnJS when JS-side logic needs to follow.
import { Pressable } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function ScaleButton({ onPress, children }) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
// Spring physics: damping and stiffness define how it settles
scale.value = withSpring(0.92, { damping: 14, stiffness: 180 });
};
const handlePressOut = () => {
scale.value = withSpring(1, { damping: 14, stiffness: 180 });
};
return (
<Animated.View style={animatedStyle}>
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={onPress}>
{children}
</Pressable>
</Animated.View>
);
}
Choosing the right motion function has a noticeable effect on how an app feels. A price change badge in a cart that slightly overshoots via withSpring feels more alive than one that fades in linearly with a fixed duration through withTiming. For progress bars, loading indicators and anything with a clearly defined target duration, though, withTiming remains the right choice, because predictability matters more than organic motion in those cases.
7. Layout animations: entering, exiting and automatic transitions
Besides classic style animation through useAnimatedStyle, Reanimated 3 offers a declarative system for layout animations that covers three events: a component being inserted (entering), a component being removed (exiting), and a change in position or size of a component that remains visible (layout). Instead of manually managing shared values, it is enough to pass a matching preset animation as a prop to an Animated.View, for example entering={FadeIn} or exiting={SlideOutLeft}. Reanimated 3 then automatically handles measuring the starting and target position, as well as the actual interpolation, entirely on the UI thread.
The layout prop is particularly useful in lists: when an element is removed from a FlatList or a manually mapped list of product cards, all subsequent elements automatically animate into their new position instead of jumping abruptly. Presets like Layout.springify() can be configured with the same spring parameters as withSpring, for example .damping(16), to match these transitions to the rest of an app's animations. Before Reanimated 3, something like this was only possible through the far more error-prone, platform-inconsistent LayoutAnimation API of React Native, which historically failed to cover many edge cases correctly on Android.
import Animated, { FadeOut, Layout, SlideInRight } from 'react-native-reanimated';
function ProductRow({ product, onRemove }) {
return (
<Animated.View
entering={SlideInRight.duration(300)}
exiting={FadeOut.duration(200)}
layout={Layout.springify().damping(16)}
style={styles.row}
>
<Text>{product.name}</Text>
<Pressable onPress={() => onRemove(product.id)}>
<Text>Remove</Text>
</Pressable>
</Animated.View>
);
}
An important point for the cart or product list of a mobile commerce shop: layout animations kick in automatically once elements are correctly identified through the React key. If a stable key is missing or an index is misused as a key, Reanimated 3 cannot reliably detect which element was removed and which was merely moved, and the transitions look jumpy instead of fluid.
8. interpolate(): mapping scroll and gesture values to visual properties
Many of the most convincing animations in mobile apps are not standalone animations at all, but derivations from a value that already exists, such as the scroll position or the finger movement during a gesture. This is exactly what interpolate() is for: the function takes an input value, an input range (inputRange) and an output range (outputRange), and calculates where within the output range the current value falls. A scroll offset from 0 to 200 pixels can be mapped directly to an opacity from 1 to 0, or a scale from 1 to 0.8, without needing a separate timing or spring animation.
Combined with useAnimatedScrollHandler, this produces a scroll effect that is entirely UI thread based: the scroll handler writes the current scroll position into a shared value, and a useAnimatedStyle callback calls interpolate(scrollY.value, [0, 100], [1, 0.9], Extrapolate.CLAMP) inside it, for example to gently shrink a product image header while scrolling. The Extrapolate.CLAMP parameter prevents the value from overshooting the defined output range when the input value leaves the defined range, for example during overscroll on iOS.
The same mechanism works for gestures: the horizontal translation of a swipe-to-dismiss gesture, processed through the gesture handler and held in a shared value, can be mapped via interpolate simultaneously onto opacity, rotation and scale of a card. All these derivations run synchronously in the same UI thread frame as the gesture itself, without a single intermediate step through the JS thread. This is the reason swipe interactions built with Reanimated 3 feel noticeably more direct than comparable implementations using the old Animated API.
9. Reanimated 3 versus the old Animated API
Moving from the built in Animated API to Reanimated 3 affects not just the syntax but the fundamental execution architecture of an animation. The following table lays out the main differences, before the most common performance pitfalls within Reanimated 3 itself are covered next.
| Dimension | Animated API (old) | Reanimated 3 | Benefit |
|---|---|---|---|
| Execution thread | JS thread, bridge serialization per frame | UI thread via worklets and JSI | No bridge round trip, stable under JS load |
| Gesture integration | Separate context, lots of manual wiring | Gesture Handler shares shared values directly | Gesture and animation in the same frame |
| Native driver limitations | Only transform and opacity natively animatable | Virtually all properties on the UI thread | No property whitelist needed anymore |
| Layout animations | Global LayoutAnimation API, unstable on Android | Declarative entering/exiting/layout per component | Granular, reliable control |
| Developer ergonomics | Imperative interpolate chains, lots of boilerplate | Declarative hooks, more compact, readable code | Less code, fewer sources of bugs |
Even with Reanimated 3 itself, typical performance pitfalls exist. The most common one: reading sharedValue.value directly on the JS thread, for example directly inside a render function or a normal useEffect, to copy a value into state. This forces a synchronous thread boundary crossing and undermines exactly the advantage shared values are meant to provide. The second common mistake is triggering unnecessary React re-renders during a running animation, for example through a runOnJS call on every frame instead of consistently expressing visual effects through useAnimatedStyle and useAnimatedProps. Both pitfalls result in an animation built with Reanimated 3 ending up dependent on the JS thread after all, and stuttering as soon as that thread comes under load.
Mironsoft
React Native apps for Magento-connected commerce shops
Mobile commerce that feels fluid?
We build React Native storefronts connected to your Magento shop, using Reanimated 3 for product cards, cart transitions and gestures that feel native rather than like a web view wrapped in an app shell.
UI Performance Audit
Analysis of JS thread load, re-renders and animation architecture in your existing app
Animation Design
Worklet-based transitions, layout animations and gestures built with Reanimated 3
App Polish
From the first product list to a polished checkout flow built to a 60fps standard
10. Summary
Reanimated 3 fundamentally changes how animations run in React Native. Instead of communicating over the bridge on every frame, worklets run directly on the UI thread and stay independent of JS thread load as a result. useSharedValue holds reactive state that communicates changes without triggering a React re-render, and useAnimatedStyle declaratively translates that state into styles that are also computed entirely on the UI thread. runOnJS and runOnUI form the controlled crossings between the two worlds, for the rare cases where a switch is genuinely necessary.
The motion functions withTiming, withSpring and withDecay together cover most animation needs, from time-based transitions to physically feeling spring motion to velocity-based decay effects. Layout animations with entering, exiting and layout replace the error-prone old LayoutAnimation API, and interpolate() connects scroll and gesture values directly to visual properties. Avoiding the typical pitfalls, above all reading shared values on the JS thread and unnecessary runOnJS calls per frame, is what makes animations built with Reanimated 3 stay at 60 frames per second even under real app load.
Reanimated 3: The Essentials at a Glance
Worklets & UI Thread
Functions marked with 'worklet' compile to run on the UI thread and stay independent of JS thread load.
Shared Values & Styles
useSharedValue holds state without re-rendering, useAnimatedStyle reactively derives styles from it.
Motion Functions
withTiming for fixed duration, withSpring for physical settling, withDecay for fling-based motion.
Layout & Interpolation
entering/exiting/layout for automatic transitions, interpolate() for scroll and gesture mapping.