Skip to content

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

FixDevs ·

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.

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} />;

For related React Native issues, see Fix: React Native Android Build Failed and Fix: Expo 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