skip to content

In a Reanimated 4 bottom sheet for a maps app, why does calling setState from a gesture or spring callback throw, and when is scheduleOnRN needed?

level: seniorimportance: must knowfreq 45%

answer

  1. callbacks are worklets on the UI runtime
  2. React lives only on the RN runtime
  3. Remote Function guard throws
  4. scheduleOnRN(fn, ...args), asynchronous
  5. cross at discrete events, not per frame

basics

~20 s

Gesture callbacks and withSpring completion callbacks are worklets on the UI runtime, where React state setters cannot be called. scheduleOnRN(fn, ...args) queues an RN-runtime function to run there; use it at discrete events like a finished snap.

solid answer

~50 s

With Reanimated installed, Gesture Handler's hook callbacks and the completion callbacks of `withTiming` and `withSpring` are workletized and run on the UI runtime. A `setState`, `navigation.navigate` or data-fetching function captured there is a plain function from the React Native runtime, so Worklets substitutes a guard that throws "Tried to synchronously call a Remote Function". `scheduleOnRN(setExpanded, true)` from `react-native-worklets` (the Reanimated 4 replacement for `runOnJS(fn)(args)`) queues the call on the RN runtime instead. The function must be defined in the component body or module scope, not inside the worklet; it runs asynchronously and returns nothing to the worklet. In a draggable sheet keep per-frame data in shared values and cross only at discrete moments, such as the snap settling, because calling it from every `onUpdate` puts a React render per frame back on the JS thread.

code

tsx · 55 lines
tsx
import { useState } from 'react';
import { StyleSheet, Text } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

const OPEN_Y = 0;
const PEEK_Y = 420;

function pickSnap(y: number, velocityY: number) {
  'worklet';
  if (velocityY < -500) return OPEN_Y;
  if (velocityY > 500) return PEEK_Y;
  return y < PEEK_Y / 2 ? OPEN_Y : PEEK_Y;
}

export function PlaceDetailsSheet({ onOpened }: { onOpened: () => void }) {
  const [expanded, setExpanded] = useState(false);
  const sheetY = useSharedValue(PEEK_Y);
  const startY = useSharedValue(PEEK_Y);

  const onSettled = (open: boolean) => {
    setExpanded(open);
    if (open) onOpened();
  };

  const pan = usePanGesture({
    onActivate: () => {
      startY.value = sheetY.value;
    },
    onUpdate: (e) => {
      sheetY.value = Math.min(PEEK_Y, Math.max(OPEN_Y, startY.value + e.translationY));
    },
    onDeactivate: (e) => {
      const target = pickSnap(sheetY.value, e.velocityY);
      sheetY.value = withSpring(target, { velocity: e.velocityY }, (finished) => {
        if (finished) scheduleOnRN(onSettled, target === OPEN_Y);
      });
    },
  });

  const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: sheetY.value }] }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.sheet, sheetStyle]}>
        <Text>{expanded ? 'Opening hours and reviews' : 'Swipe up for details'}</Text>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  sheet: { position: 'absolute', left: 0, right: 0, top: 80, bottom: 0, backgroundColor: 'white' },
});

go deeper

for a junior

Recall that gesture and animation callbacks run on the UI thread, React state lives on the JS thread, and scheduleOnRN is how Reanimated 4 code crosses from one to the other.

for a middle

Explain the two runtimes, why a captured setter becomes a throwing remote function, and scheduleOnRN's rules: RN-scope function, copied arguments, asynchronous, no return value.

for a senior

Design the boundary: per-frame work in shared values and worklet helpers, one crossing per meaningful event, useAnimatedReaction for thresholds, and no per-frame React renders from a drag.

for a principal

Discuss how a team documents which state is UI-runtime and which is React, and reviews gesture code for boundary crossings so a sheet stays smooth while the JS thread is busy.

## Two runtimes, one app Reanimated 4 relies on `react-native-worklets`, which distinguishes two kinds of JavaScript runtime: - The **React Native runtime** (RN runtime), executed by the JS thread. React, your components, their state setters, React Navigation, `fetch` calls and most third-party libraries live here. There is exactly one. - The **UI runtime**, a worklet runtime executed mostly by the UI (main) thread. It runs animation frames and handles native events such as touches on the same frame they happen. The two runtimes **do not share memory**. They communicate only through specific APIs: shared values for data, `scheduleOnUI` to run a worklet on the UI runtime, and `scheduleOnRN` to run a function on the RN runtime. ## Which of your callbacks are on the UI runtime In a bottom sheet over a map, several callbacks you write look like ordinary functions but are **worklets**: - the callbacks of a Gesture Handler gesture such as `usePanGesture({ onUpdate, onDeactivate })`, which run on the UI thread by default when Reanimated is installed; - the third-argument completion callbacks of `withSpring` and `withTiming`; - the updaters of `useAnimatedStyle` and `useDerivedValue`, and the callbacks of `useAnimatedReaction`. When such a worklet references a function from the component, the Worklets Babel plugin captures it. If that function is itself a worklet, it is copied and can be called synchronously. If it is a plain RN-runtime function, like the setter from `useState`, the UI runtime receives a **remote function** stand-in whose only behaviour is to throw: `[Worklets] Tried to synchronously call a Remote Function. Called "setExpanded" on the UI Runtime.` ## What `scheduleOnRN` does `scheduleOnRN(fn, ...args)` asks the RN runtime to run `fn(...args)`: 1. It returns `void` immediately; the worklet keeps running. 2. From a worklet runtime, the call is forwarded to the RN runtime through React Native's scheduling, so `fn` runs later on the JS thread, not in the current frame. 3. The arguments are copied to the RN runtime, so pass plain data such as booleans, numbers and small objects. Two rules follow: - **`fn` must be defined on the RN runtime**, in the component body or at module scope. A function created inside the worklet (`const log = () => {}; scheduleOnRN(log)`) exists only on the UI runtime and fails. - **Nothing comes back.** If the JS side needs to report something to the animation, it writes a shared value. Reanimated 3's `runOnJS(fn)(...args)` became `scheduleOnRN(fn, ...args)`: the arguments are no longer passed through a second call. `runOnJS` is still re-exported from Reanimated 4 but deprecated. Called from the RN runtime itself, `scheduleOnRN` simply queues a microtask. ## Designing the sheet around the boundary | Need | Keep on UI runtime | Cross with `scheduleOnRN` | |---|---|---| | Sheet follows the finger | Write `translateY` in `onUpdate` | Never, this is per frame | | Pick the snap point | A `'worklet'` helper called synchronously | No | | Sheet settles fully open | The spring's completion callback | `setExpanded(true)` once | | Load place details when opened | No | Call the loader or navigate | The failure pattern to avoid is `onUpdate: (e) => scheduleOnRN(setOffset, e.translationY)`. It does not block the UI thread, but it schedules a React render for every touch event, which is exactly the JS-thread traffic that moving the animation to the UI runtime was meant to remove. When the JS thread is busy, those renders queue up and the React-side UI lags behind the sheet. ## When not to cross at all - **Small pure helpers** such as clamping or choosing a snap point: add `'worklet'` and call them directly on the UI runtime. - **Threshold reactions**: `useAnimatedReaction` watches a shared value on the UI runtime and can call `scheduleOnRN` only when a threshold is crossed, instead of on every change. ## Why this needs the New Architecture `scheduleOnRN` ships in `react-native-worklets`, which, like Reanimated 4, targets the New Architecture; Reanimated 4 does not support the legacy one. On React Native 0.82 and later the New Architecture is the only architecture, so on 0.87 this holds by default. The reason it matters in interviews is historical: projects stuck on the legacy architecture stay on Reanimated 3 and keep the older `runOnJS` form.

  • The sheet must tell the map to dim its markers when it passes the halfway point. How do you avoid calling scheduleOnRN on every frame?
    Watch the shared value with `useAnimatedReaction`, which runs on the UI runtime and receives the current and previous values. Call `scheduleOnRN` only when the value crosses the threshold in either direction, so React sees one update per crossing instead of one per frame.
  • Why does scheduleOnRN(() => setExpanded(true)) written inside onDeactivate fail even though setExpanded exists in the component?
    The arrow function is created inside the worklet, so it exists only on the UI runtime. `scheduleOnRN` needs a function defined on the RN runtime. Pass the component-scope function and its argument directly, `scheduleOnRN(setExpanded, true)`, or a component-scope wrapper.
  • How would you get a value computed on the JS side, such as a server-provided snap height, into the running gesture?
    `scheduleOnRN` returns nothing to the worklet, so data flows back through shared values. Write the height into a shared value from the RN runtime, for example in an effect after the fetch, and read it in the gesture and snap worklets.

saying these in an interview costs you the question

  • Gesture Handler callbacks run on the JS thread, so setState can be called directly in them.
  • scheduleOnRN blocks the UI thread until the React render finishes.
  • scheduleOnRN returns the function's result to the calling worklet.
  • In Reanimated 4 you still write runOnJS(fn)(args) as the recommended form.
  • Calling scheduleOnRN from onUpdate on every frame is fine because it is asynchronous.
  • Any function, including one created inside the worklet, can be passed to scheduleOnRN.