PanResponder computes its delta in JS on every touch move. Fine, until the JS thread is busy re-rendering or waiting on a callback. Then the dragged card trails your finger.
#The old way
import { useRef } from 'react';
import { Animated, PanResponder, StyleSheet } from 'react-native';
export function DraggableCard() {
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
useNativeDriver: false,
}),
onPanResponderRelease: () => {
Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false }).start();
},
})
).current;
return (
<Animated.View
{...panResponder.panHandlers}
style={[styles.card, { transform: pan.getTranslateTransform() }]}
/>
);
}
const styles = StyleSheet.create({
card: { width: 120, height: 160, borderRadius: 16, backgroundColor: '#3478f6' },
});
#The new way
import { StyleSheet } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
export function DraggableCard() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const pan = Gesture.Pan()
.onChange((event) => {
translateX.value += event.changeX;
translateY.value += event.changeY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]} />
</GestureDetector>
);
}
const styles = StyleSheet.create({
card: { width: 120, height: 160, borderRadius: 16, backgroundColor: '#3478f6' },
});
Three worklets hide in there: onChange, onEnd, and the function handed to useAnimatedStyle. The Babel plugin compiles each one to run on the UI thread's own JS runtime, in step with the gesture.
Nothing crosses the bridge per frame.
#Why it matters
- Gesture handling and style recalculation both land on the UI thread. Animations stay smooth even when the JS thread is blocked.
- Physics too.
withSpring/withTimingcompute on the UI thread, not just the final style write. - The API reads like plain React state. But nothing here waits on a JS thread that might be busy.
#Gotcha
Worklets live in a separate JS runtime on the UI thread. They close over shared values, other worklets, and plain serializable data. Nothing else.
Reference a normal JS variable, call a non-worklet function, or call setState directly inside onChange, and you get a throw or a silent no-op. Hop back to the JS thread explicitly with runOnJS().