Fix: React Native Reanimated Not Working — Worklet Error, useAnimatedStyle Not Updating, or Gesture Not Responding
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix React Native Reanimated issues — worklet rules, shared values, useAnimatedStyle, Gesture Handler setup, web support, Babel plugin configuration, and Reanimated 3 migration.
The Problem
An animation throws a worklet error at runtime:
[Reanimated] Tried to synchronously call a non-worklet function on the UI thread.Or useAnimatedStyle doesn’t update when a shared value changes:
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
return { transform: [{ translateX: offset.value }] };
});
offset.value = 100; // Style doesn't update — no animationOr gesture handling doesn’t work after installing react-native-gesture-handler:
[Unhandled promise rejection: Error: Default navigator appeared more than once]Or animations work on iOS but not Android:
TypeError: undefined is not an object (evaluating 'style.transform')Why This Happens
Reanimated runs animations on the UI thread, separate from the JavaScript thread:
- Worklets must be pure — functions that run on the UI thread (worklets) can only access their own arguments and global constants. Closures over JavaScript objects, React state, or refs that aren’t shared values cause worklet errors.
shared valuechanges needwithTimingorwithSpringfor animation — settingoffset.value = 100moves instantly (no animation). Useoffset.value = withTiming(100)to animate.- Gesture Handler requires wrapping your app root —
GestureHandlerRootViewmust wrap the entire app, not just the screen using gestures. Missing this wrapper causes subtle failures. - Babel plugin is required — Reanimated uses a Babel plugin to transform worklets at build time. Without it, worklets run on the JS thread and cause the synchronous call error.
The deeper architectural fact is that Reanimated owns a second JavaScript runtime that runs on the UI thread. The Babel plugin scans your code for the 'worklet' directive (or wrappers like useAnimatedStyle) and serializes those function bodies so the UI runtime can execute them without going through the React Native bridge. That is what makes 60 fps animations possible while the JS thread is busy. It is also why closures over React state, refs, or imported variables fail: those values live in the JS runtime’s heap and cannot be reached from the UI runtime. useSharedValue exists specifically to provide a two-runtime-safe handle.
A second source of confusion is the v2-to-v3 migration. Reanimated 2 used hooks like useAnimatedGestureHandler tied to Gesture Handler v1’s PanGestureHandler component. Reanimated 3 with Gesture Handler v2 uses the imperative Gesture.Pan() builder and GestureDetector. The two APIs look similar but are not interchangeable, and tutorials older than 2023 mostly use v2 patterns that no longer compile against current types. Pin the Reanimated version explicitly and read the version-matched docs.
How Other Tools Handle This
The React Native animation landscape splits into UI-thread and JS-thread tools, and most “not working” pain comes from picking the wrong one for the job.
Reanimated vs the built-in Animated API. React Native’s bundled Animated API drives values from JS and pushes updates over the bridge to the native view. With useNativeDriver: true, transforms and opacity run on the native side without per-frame bridge traffic, but layout properties (width, height, top) cannot use the native driver and stutter when the JS thread is busy. Reanimated runs the entire animation on the UI thread regardless of property, which is why complex sequences stay smooth during heavy JS work. The cost is the worklet rules and the second runtime.
Reanimated vs Moti. Moti is a declarative wrapper on top of Reanimated. You pass from and animate props and Moti generates the shared values and animated styles for you. For 80% of UI animations (fade in, slide in, expand), Moti is dramatically shorter. You drop down to raw Reanimated only when you need gesture-driven values or custom interpolation. If you hit a worklet error in Moti, the fix is still a Reanimated fix.
Reanimated vs Lottie. Lottie plays pre-rendered After Effects animations from a JSON file. You don’t write animation logic at all — designers ship the JSON. Lottie is the right choice for branded illustrations, loading spinners, and onboarding sequences. It is the wrong choice for interactive animation that responds to scroll position or gestures.
Reanimated vs Skia (@shopify/react-native-skia). Skia exposes a full 2D rendering API. For canvas-style effects (particles, custom charts, image filters), Skia wins decisively because Reanimated only animates view properties. Skia and Reanimated combine well: drive Skia values with Reanimated shared values to get UI-thread paint updates.
Picking the right tool. Use Animated for trivial one-shot animations with native driver. Use Moti for declarative component-level motion. Use Reanimated when you need gesture interplay, complex sequences, or layout animations. Use Lottie for designer-authored playback. Use Skia for anything pixel-level.
Fix 1: Configure the Babel Plugin
// babel.config.js — Reanimated plugin MUST be last
module.exports = {
presets: ['babel-preset-expo'], // or 'module:@react-native/babel-preset'
plugins: [
// Other plugins BEFORE reanimated
['@babel/plugin-proposal-decorators', { legacy: true }],
// ...
// react-native-reanimated MUST be the last plugin
'react-native-reanimated/plugin',
],
};After changing babel.config.js, clear the cache:
npx expo start --clear
# OR
npx react-native start --reset-cacheVerify the plugin is working:
// If this works without error, the plugin is configured correctly
import { runOnUI } from 'react-native-reanimated';
runOnUI(() => {
'worklet';
console.log('Running on UI thread');
})();Fix 2: Use Shared Values and Animated Styles Correctly
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
withRepeat,
withSequence,
Easing,
runOnJS,
} from 'react-native-reanimated';
// Shared value — the reactive primitive of Reanimated
const offset = useSharedValue(0);
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
// Animated style — reads shared values, runs on UI thread
const animatedStyle = useAnimatedStyle(() => {
// 'worklet' is implicit here — useAnimatedStyle wraps it
return {
transform: [
{ translateX: offset.value },
{ scale: scale.value },
],
opacity: opacity.value,
};
});
// WRONG — instant jump, no animation
offset.value = 100;
// CORRECT — animated transitions
offset.value = withTiming(100, {
duration: 300,
easing: Easing.out(Easing.quad),
});
offset.value = withSpring(100, {
damping: 10,
stiffness: 100,
});
// Sequence — chain animations
offset.value = withSequence(
withTiming(100, { duration: 200 }),
withTiming(-100, { duration: 200 }),
withTiming(0, { duration: 200 }),
);
// Repeat
scale.value = withRepeat(
withTiming(1.2, { duration: 500 }),
-1, // -1 = infinite
true, // reverse = true (bounces back)
);
// Apply to Animated component
return (
<Animated.View style={[styles.box, animatedStyle]}>
<Text>Animated!</Text>
</Animated.View>
);Run JS callbacks from animations (call JS from UI thread):
const handleComplete = () => {
// This runs on JS thread
setAnimationDone(true);
};
offset.value = withTiming(100, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(handleComplete)(); // Bridge back to JS thread
}
});Fix 3: Fix Worklet Errors
Worklets are UI-thread functions. They have strict rules:
// WRONG — accessing React state in a worklet
const [isActive, setIsActive] = useState(false);
const animatedStyle = useAnimatedStyle(() => {
return {
backgroundColor: isActive ? 'blue' : 'red', // isActive isn't a worklet-safe value
};
});
// CORRECT — use shared value for worklet-accessible state
const isActive = useSharedValue(false);
const animatedStyle = useAnimatedStyle(() => {
return {
backgroundColor: isActive.value ? 'blue' : 'red',
};
});
// Update from JS:
isActive.value = true;
// WRONG — calling a regular function inside useAnimatedStyle
function formatColor(value: number) {
return `hsl(${value}, 100%, 50%)`; // Regular JS function
}
const animatedStyle = useAnimatedStyle(() => {
return { backgroundColor: formatColor(hue.value) }; // Error!
});
// CORRECT — mark helper functions as worklets
function formatColor(value: number) {
'worklet';
return `hsl(${value}, 100%, 50%)`;
}
// OR use worklet-safe utilities from Reanimated
import { interpolateColor } from 'react-native-reanimated';
const animatedStyle = useAnimatedStyle(() => {
return {
backgroundColor: interpolateColor(
progress.value,
[0, 1],
['red', 'blue']
),
};
});
// WRONG — accessing an array/object from JS scope
const positions = [0, 100, 200];
const animatedStyle = useAnimatedStyle(() => {
return { translateX: positions[index.value] }; // positions isn't a worklet-safe ref
});
// CORRECT — use useDerivedValue or inline the data
const animatedStyle = useAnimatedStyle(() => {
const positions = [0, 100, 200]; // Defined inside — worklet-safe
return { transform: [{ translateX: positions[index.value] }] };
});Fix 4: Set Up Gesture Handler
npm install react-native-gesture-handler
# OR with Expo:
npx expo install react-native-gesture-handler// App.tsx — GestureHandlerRootView MUST wrap the entire app
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<NavigationContainer>
{/* Everything else */}
</NavigationContainer>
</GestureHandlerRootView>
);
}Gesture Handler v2 API (Reanimated 3):
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function DraggableBox() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedX = useSharedValue(0);
const savedY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
savedX.value = translateX.value;
savedY.value = translateY.value;
})
.onUpdate((event) => {
translateX.value = savedX.value + event.translationX;
translateY.value = savedY.value + event.translationY;
})
.onEnd(() => {
// Snap back to origin
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
);
}
// Tap gesture with scale feedback
function TapButton() {
const scale = useSharedValue(1);
const tapGesture = Gesture.Tap()
.onBegin(() => { scale.value = withSpring(0.95); })
.onFinalize(() => { scale.value = withSpring(1); });
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<GestureDetector gesture={tapGesture}>
<Animated.View style={[styles.button, animatedStyle]}>
<Text>Press me</Text>
</Animated.View>
</GestureDetector>
);
}
// Compose gestures
const composed = Gesture.Simultaneous(panGesture, pinchGesture);
const exclusive = Gesture.Exclusive(tapGesture, longPressGesture);Fix 5: Layout Animations
import Animated, {
FadeIn,
FadeOut,
SlideInLeft,
SlideOutRight,
ZoomIn,
Layout,
LinearTransition,
} from 'react-native-reanimated';
// Enter/exit animations
function AnimatedItem({ visible }: { visible: boolean }) {
return visible ? (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(300)}
>
<Text>I animate in and out!</Text>
</Animated.View>
) : null;
}
// Layout animation — animates when item moves in a list
function AnimatedList({ items }: { items: string[] }) {
return (
<>
{items.map((item, index) => (
<Animated.View
key={item}
layout={LinearTransition} // Animates position change
entering={SlideInLeft}
exiting={SlideOutRight}
>
<Text>{item}</Text>
</Animated.View>
))}
</>
);
}
// Custom entering animation
const CustomEnter = FadeIn
.duration(500)
.easing(Easing.out(Easing.quad))
.delay(100);Fix 6: Reanimated on Web
Reanimated v3 supports React Native Web:
# Ensure you have the web dependencies
npx expo install react-dom react-native-web @expo/webpack-config// babel.config.js — additional config for web
module.exports = {
presets: ['babel-preset-expo'],
plugins: [
'react-native-reanimated/plugin',
],
env: {
production: {
plugins: ['react-native-reanimated/plugin'],
},
},
};Web limitations:
// Not all Reanimated features work on web
// Avoid:
// - runOnUI() / runOnJS() for complex UI interactions
// - Some gesture recognizers behave differently
// Use platform checks for incompatible code
import { Platform } from 'react-native';
const gesture = Platform.OS === 'web'
? Gesture.Native() // Fallback for web
: Gesture.Pan().onUpdate(handler);Still Not Working?
Animations work in development but not in production — check that the Babel plugin is running in production mode. Some build pipelines strip development-only transforms. Add the plugin explicitly to the production env in babel.config.js (see Fix 1). Also ensure react-native-reanimated isn’t being excluded by metro or bundler configuration.
useAnimatedStyle returns stale values — shared values are tracked by reference. If you create a shared value inside a conditional or callback that re-runs, you’re getting a new shared value on each run. Always create shared values at the top of your component or outside the component:
// WRONG — new shared value on each render if condition changes
function MyComponent({ active }) {
const scale = useSharedValue(active ? 1.2 : 1); // Re-creates!
}
// CORRECT — single value, update via effect or gesture
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withSpring(active ? 1.2 : 1);
}, [active]);ScrollView vs FlatList with Reanimated — use Animated.ScrollView and Animated.FlatList (from react-native-reanimated) instead of wrapping RN’s components. The useAnimatedScrollHandler hook handles scroll events on the UI thread:
import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated';
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
return <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16} />;Hermes vs JSC behavior diverges — Reanimated 3 supports both Hermes and JavaScriptCore, but worklet serialization paths differ. If you flip the hermesEnabled flag mid-project and start hitting “function is not a worklet” at runtime, run npx react-native clean (or expo prebuild --clean) before rebuilding. Stale Hermes bytecode caches keep old worklet shapes.
Layout animations skipped on Android — entering={FadeIn} and layout={LinearTransition} rely on the new architecture’s ReactNativeFeatureFlags. On Android with the old architecture you must call setLayoutAnimationEnabledExperimental(true) early in the app, otherwise entering/exiting are no-ops. Migrate to the new architecture or use the explicit useAnimatedStyle path for cross-version reliability.
Fast Refresh re-mounts components and shared values reset — every reload creates new shared values, so animations restart from initial state and may briefly jump. This is correct behavior, not a bug. For dev-only smoothness, persist key shared values via a singleton outside the component.
For related React Native issues, see Fix: React Native Android Build Failed and Fix: Expo Not Working. For comparable animation/library debugging, see Fix: Lottie Not Working and Fix: React Native Paper Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Expo Not Working — Build Failing, Native Module Not Found, or EAS Build Error
How to fix Expo issues — Expo Go vs development builds, native module installation with expo-modules-core, EAS Build configuration, bare workflow setup, and common SDK upgrade problems.
Fix: React Native Android Build Failed
How to fix React Native Android build failures — SDK version mismatches, Gradle errors, duplicate module issues, Metro bundler problems, and NDK configuration for common build errors.
Fix: React Native Metro Bundler Failed to Start or Bundle
How to fix React Native Metro bundler errors — unable to resolve module, EMFILE too many open files, port already in use, transform cache errors, and Metro failing to start on iOS or Android.
Fix: Deno PermissionDenied — Missing --allow-read, --allow-net, and Other Flags
How to fix Deno PermissionDenied (NotCapable in Deno 2) errors — the right permission flags, path-scoped permissions, deno.json permission sets, and the Deno.permissions API.