You add React Native Reanimated to your Rork project expecting a few slick transitions, and instead you get a red screen full of Reanimated 2 failed to create a worklet or Tried to synchronously call function from a different thread. If that scenario sounds familiar, you are not alone — I once spent half a day tracking down a worklet error that only appeared on a physical device after an EAS build, even though everything ran fine inside Rork Companion.
Reanimated worklets look like ordinary functions, but they are compiled by a Babel plugin to run on the UI thread. That transformation is what makes them fast, and it is also the reason the same-looking code can work in one place and fail in another. Below is the order I now use to diagnose worklet-related errors in Rork projects. It targets Reanimated v3, but the same logic applies to v2.
Identify Which Layer Is Actually Failing
Worklet errors fall into three layers, and knowing which one you are in saves hours of guesswork.
- Build-time failure — the Babel plugin never ran, so your dev build fails at boot.
- Initial render failure — calls like
useAnimatedStyleblow up the first time the component mounts. - Runtime thread-boundary failure — gestures or animations run, but the moment they fire you see a crash.
Read the error message before touching any code. Reanimated 2 failed to create a worklet, maybe you forgot to add Reanimated's babel plugin? is a layer-one symptom. A ReferenceError inside a worklet points at layer two. Anything mentioning "synchronously call function" or "different thread" is layer three. Skipping this step is how people fix the same issue three different ways and still end up stuck.
1. Make Sure react-native-reanimated/plugin Is in babel.config.js
This is the single most common issue. Rork-generated projects ship with only the Expo preset, and installing Reanimated manually does not touch the Babel config.
// Bad — no Reanimated plugin configured
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};// Good — plugin added as the LAST entry
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-reanimated/plugin"],
};
};The plugin must be last in the array — the official docs emphasise this because the worklet transform has to run after everything else. After updating the file, restart Metro with npx expo start --clear so the transform cache is rebuilt. If you skip the cache clear, the plugin will look installed but still not apply.
A subtle follow-on issue: even after the plugin is in place and the cache is cleared, a stale Metro process can keep serving the old bundle. If you still see the plugin error after a restart, kill all running Metro instances with killall -9 node (or Activity Monitor / Task Manager) before starting Metro again. It sounds excessive, but I have watched the same phantom error survive three restarts until the stray process was killed.
2. Rebuild the Native Layer
Reanimated ships a native module, so updating the JS side alone will not do anything once your bundle includes a previous version. If your animation works in Rork Companion but fails on a real device build, this is almost always the problem.
# Rebuild the dev client from scratch
npx expo prebuild --clean
npx expo run:ios # or run:android
# Clear EAS build cache if the error persists
eas build --platform ios --clear-cacheprebuild --clean regenerates the ios/ and android/ folders. If you have hand-edited anything inside them, stash those changes first. For stock Rork-generated projects you can run it as-is without worry.
3. Don't Call Plain JS Functions From Inside a Worklet
The most important Reanimated rule: only worklets can call worklets. Rork-generated code sometimes drops helper utilities straight into useAnimatedStyle, which compiles fine but crashes the moment the worklet runs.
// Bad — worklet calls a regular JS function
import { useAnimatedStyle } from "react-native-reanimated";
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
const style = useAnimatedStyle(() => {
// Runtime: "Tried to synchronously call function clamp"
const opacity = clamp(progress.value, 0, 1);
return { opacity };
});// Good — mark the helper as a worklet
function clamp(value: number, min: number, max: number) {
"worklet";
return Math.min(Math.max(value, min), max);
}
const style = useAnimatedStyle(() => {
const opacity = clamp(progress.value, 0, 1);
return { opacity };
});The "worklet"; directive on the first line tells the Babel plugin to compile the function as a worklet too. I recommend keeping shared helpers in a file like utils/worklets.ts with the directive on every function, so there is no ambiguity when you reuse them.
4. Use runOnJS When You Need the JS Thread
It's tempting to call setState or trigger navigation straight from a worklet, but doing so throws the same cross-thread error as Pattern 3. Wrap the call in runOnJS to hop back to the JS thread explicitly.
// Bad — setState called directly from a worklet
import { useAnimatedGestureHandler } from "react-native-reanimated";
const gestureHandler = useAnimatedGestureHandler({
onEnd: () => {
setSwipeCount(prev => prev + 1); // JS function called from UI thread
},
});// Good — runOnJS bridges back to the JS thread
import { useAnimatedGestureHandler, runOnJS } from "react-native-reanimated";
const gestureHandler = useAnimatedGestureHandler({
onEnd: () => {
runOnJS(setSwipeCount)(swipeCount + 1);
},
});runOnJS serialises values only, so functional updates like setSwipeCount(prev => prev + 1) will not work when passed directly. If you need that pattern, create a small JS-side wrapper that takes the current value and calls the setter internally.
5. Watch Out for Stale Closures
Worklets capture values at the moment they are defined. Reference a React state or prop directly and you will keep seeing the old value — the animation runs, but it never reacts to the latest data. This one is particularly confusing because nothing crashes; the UI just feels broken.
// Bad — worklet captures the initial threshold value
const [threshold, setThreshold] = useState(100);
const style = useAnimatedStyle(() => {
return { opacity: progress.value > threshold ? 1 : 0 };
});There are two clean ways to fix this. The first is to hold the value in a shared value:
// Good — move threshold into useSharedValue
const threshold = useSharedValue(100);
// Update like this
threshold.value = 150;
const style = useAnimatedStyle(() => {
return { opacity: progress.value > threshold.value ? 1 : 0 };
});The second is useDerivedValue, which is handy when the JS-side value comes from a library you cannot refactor:
// Good — bridge a JS value into a shared value
const thresholdShared = useDerivedValue(() => {
return threshold;
}, [threshold]);Don't forget the dependency array — miss it and the bridge silently stops updating. Think of it exactly like useEffect dependencies.
6. Check Expo Go and Hermes Versions
This one is easy to miss. If you test with an older Expo Go client, its bundled Reanimated version may not match your project, and worklets simply fail to run. When Rork Companion and the physical device disagree about whether your animation works, check that your app.json runtimeVersion matches the Expo Go build you have installed.
# Verify Reanimated is on the Expo-recommended version
npx expo install --check
# Expect: react-native-reanimated listed at the SDK-recommended versionDrift away from Expo's recommended version and you may hit unimplemented APIs or mismatched worklet transforms. Running npx expo install react-native-reanimated re-aligns you. For anything beyond quick prototyping, build a proper development client so you stop depending on Expo Go's pinned version altogether — most of the inconsistency problems evaporate once you leave Expo Go behind.
A quick reality check: most Rork users will not hit version mismatches if they stay inside the Rork Companion + EAS workflow without side-installing Reanimated manually. The cases I see most often involve someone upgrading Expo SDK on their own and forgetting to run npx expo install --check afterwards. Treat that command as part of every upgrade and you will dodge this category of bug entirely.
One practical workflow tip: keep a minimal reproduction scene in your project while you are learning Reanimated. A single screen with a spring animation and a pan gesture gives you a sandbox to test each worklet pattern in isolation. When an error appears in your real app, copy the failing snippet into the sandbox and work from there. Diagnosing worklets inside a busy screen full of other logic is how small mistakes turn into full-day debugging sessions.
What to Do Next
Worklet errors look like code problems but are usually build, thread, or cache problems wearing a disguise. The single highest-return move you can make today is to open babel.config.js, confirm react-native-reanimated/plugin is the final entry, and rebuild the dev client with --clear. That alone resolves more than half of the cases I have seen.
If you are moving into more elaborate gesture and animation work, skim Advanced animations in Rork with Reanimated and Gesture Handler and A complete guide to debugging Rork app crashes and white screens. Reading those two together gives you a mental model of where the UI thread ends and the JS thread begins — which is the real key to avoiding worklet errors in the first place.