Skip to content

Fix: React Native Reanimated Not Working — Worklet Error, useAnimatedStyle Not Updating, or Gesture Not Responding

FixDevs · (Updated: )

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 animation

Or 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 value changes need withTiming or withSpring for animation — setting offset.value = 100 moves instantly (no animation). Use offset.value = withTiming(100) to animate.
  • Gesture Handler requires wrapping your app rootGestureHandlerRootView must 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-cache

Verify 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 Androidentering={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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles