In React Native Gesture Handler 3 with Reanimated installed, why does calling setState from a gesture's onDeactivate throw, and how do you fix it?
answer
- inline callbacks are worklets
- worklets run on the UI runtime
- a captured JS function is remote
- runOnJS moves callbacks to JS
- or schedule the call back to JS
basics
~20 sWith Reanimated installed, inline gesture callbacks run as worklets on the UI thread, and a React state setter captured from the JS runtime cannot be called synchronously there. Set runOnJS: true on the gesture, or schedule the call back to the JS thread.
solid answer
~50 sIn Gesture Handler 3's hook API, callbacks written inline in the config are workletized automatically and, because `runOnJS` defaults to false, run on the UI runtime. A worklet can read and write shared values, but a plain JavaScript function it captured, such as a `useState` setter or `navigation.navigate`, arrives as a remote function, and calling it synchronously throws a Worklets error saying it tried to synchronously call a Remote Function. There are two fixes. Keep UI-thread work in the worklet and hand the JS call back with `scheduleOnRN(fn, ...args)` from `react-native-worklets`. Or, if the gesture does nothing that needs the UI thread, set `runOnJS: true`, or a shared value that toggles it, so its callbacks run on the JS thread and can call React directly. Choose per gesture: continuous `onUpdate` work belongs on the UI thread, while a one-off end-of-gesture action can go to JS.
code
tsx · 33 linesimport {useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {GestureDetector, useTapGesture} from 'react-native-gesture-handler';
import {scheduleOnRN} from 'react-native-worklets';
export function PhotoWithToolbar() {
const [toolbarVisible, setToolbarVisible] = useState(true);
const toggle = () => setToolbarVisible(v => !v);
// Throws on the UI runtime: onDeactivate: () => toggle()
const tap = useTapGesture({
onDeactivate: e => {
if (!e.canceled) {
scheduleOnRN(toggle);
}
},
});
// Alternative: useTapGesture({ runOnJS: true, onDeactivate: e => { if (!e.canceled) toggle(); } })
return (
<GestureDetector gesture={tap}>
<View style={styles.photo}>
{toolbarVisible && <View style={styles.toolbar} />}
</View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
photo: {flex: 1, backgroundColor: 'black'},
toolbar: {height: 56, backgroundColor: 'rgba(0,0,0,0.6)'},
});go deeper
Recall that with Reanimated installed, gesture callbacks run on the UI thread, where React state setters cannot be called directly.
Explain automatic workletization, the runOnJS default, and why a captured JS function becomes a remote function on the UI runtime.
Pick the fix per gesture: worklets plus scheduleOnRN for continuous work, runOnJS or disableReanimated for JS-only taps, and watch for stale captured state.
Set conventions for where gesture logic lives, UI thread versus JS, so the codebase keeps smooth gestures without scattering thread bugs.
## The symptom In the photo viewer, a single tap should hide the toolbar, which lives in React state: - the tap is built with `useTapGesture({ onDeactivate: () => setToolbarVisible(v => !v) })`; - it throws an error from Worklets saying it **tried to synchronously call a Remote Function** on the UI runtime. The same happens with `navigation.navigate`, an analytics call, or any function that lives in the JavaScript runtime. ## Why it happens 1. With Reanimated installed, Gesture Handler 3's hook API **integrates with Reanimated by default**. 2. Callbacks written **inline in the config** are automatically marked as **worklets** by the worklets Babel plugin. 3. **`runOnJS` defaults to `false`**, so those worklets run on the **UI runtime**, the JavaScript runtime that executes on the UI thread. 4. A worklet that captures an ordinary JS function, such as a state setter, receives a **remote function** in its place. Calling it synchronously from the UI runtime throws. The error is the library protecting you: the UI runtime cannot run React's state machinery, which lives in the main JavaScript runtime. ## Fix 1: schedule the call back to the JS thread Keep the callback on the UI thread and hand the JS-only part back: - call `scheduleOnRN(setToolbarVisible, next)` from `react-native-worklets` inside the worklet; - the call runs later on the JS thread, and the worklet continues without waiting. This is the right fix when the gesture also does UI-thread work, such as updating shared values in `onUpdate`. ## Fix 2: run the gesture's callbacks on the JS thread - Set **`runOnJS: true`** in the gesture config, and all its callbacks run on the JavaScript thread, where they can call React directly. - `runOnJS` can also be a **shared value**, so a gesture can switch between threads during its lifecycle. - **`disableReanimated: true`** turns the integration off completely for a gesture that never touches Reanimated. Recognition itself stays native in every case; only where your callbacks execute changes. ## Choosing between them | Situation | Better choice | Why | |---|---|---| | Pinch or pan updating shared values every frame | keep worklets, `scheduleOnRN` for rare JS calls | per-frame work must not wait for JS | | Tap that only toggles React state | `runOnJS: true` | nothing benefits from the UI thread | | Gesture with both kinds of work | worklets plus `scheduleOnRN` at the end | the continuous part stays smooth | | Gesture that never uses Reanimated | `disableReanimated: true` | removes the integration overhead | ## Related traps - **Losing workletization:** a callback moved into `useCallback` or defined outside the config is no longer workletized automatically and needs a `'worklet'` directive, which changes which thread it runs on. - **Reading React state in a worklet:** a worklet captures a copy of the values at the time it was created, so reading `toolbarVisible` inside it can be stale; keep such state in a shared value, or do the logic on the JS side. - **Mixing Reanimated and Animated** in one detector is not supported. ## What interviewers listen for - That the candidate knows callbacks run on the UI thread by default in this setup. - That they can name both fixes and choose by the kind of work. - That they do not "fix" it by moving every gesture to JS, which throws away the reason for using the library. ## How to recognise it quickly - The error names a **Remote Function** and a runtime other than the main React Native runtime. - The stack points at a gesture callback written inline in a `use*Gesture` config. - The same code works on a setup without Reanimated, because there the callbacks run in JavaScript. - Adding `runOnJS: true` makes the error disappear, which confirms the diagnosis but is not automatically the best fix. Once recognised, decide per gesture which work must stay on the UI thread and route everything else back to JavaScript explicitly.
- In Gesture Handler 3, can runOnJS change during a gesture?Yes. `runOnJS` accepts a shared value as well as a boolean, and the documentation describes changing it dynamically throughout the gesture's lifecycle. A gesture can therefore run its callbacks on the UI thread most of the time and switch to the JS thread when it needs to, without re-rendering to rebuild the config.
- Why is moving every gesture to runOnJS: true a poor general fix?It removes the UI-thread callbacks that make Gesture Handler with Reanimated smooth under load. Recognition stays native, but every `onUpdate` now waits for the JavaScript thread, so a pinch or drag stutters whenever JS is busy. Keep continuous work in worklets and send only the occasional JS-only call back with `scheduleOnRN`.
saying these in an interview costs you the question
- Gesture callbacks always run on the JavaScript thread
- Wrapping setState in useCallback lets a worklet call it synchronously
- runOnJS: true also moves gesture recognition to the JavaScript thread
- A worklet can safely read the latest React state it closed over
- The fix is to set runOnJS: true on every gesture in the app