skip to content

In React Native, why does a Gesture Handler pan keep tracking the finger while the JavaScript thread is busy, when a PanResponder drag stalls?

level: middleimportance: must knowfreq 55%

answer

  1. recognition happens in native code
  2. UIGestureRecognizer on iOS, orchestrator on Android
  3. callbacks become worklets with Reanimated
  4. runOnJS defaults to false
  5. JS-side callbacks still wait for JS

basics

~20 s

Gesture Handler recognises gestures natively on the UI thread, and with Reanimated installed its hook callbacks run as worklets on the UI thread too. PanResponder decides and reacts in JavaScript, so a busy JS thread delays every move.

solid answer

~50 s

PanResponder is built on React Native's responder system, which negotiates the touch and calls every handler in JavaScript, so each move waits for the JS thread. Gesture Handler moves recognition into native code: on iOS each gesture is a `UIGestureRecognizer`, and on Android the library runs its own orchestrator that intercepts touches under `GestureHandlerRootView`. Activation, failure and the relations between gestures are decided there. When Reanimated is installed, callbacks written inline in a hook's config are workletized automatically and run on the UI thread, because `runOnJS` defaults to false, so `onUpdate` can update shared values that drive styles without involving JavaScript. The benefit shrinks when you opt out: with `runOnJS: true`, `disableReanimated`, or no Reanimated, recognition stays native but your callbacks run in JS and wait behind other work. The full answer is native recognition plus UI-thread callbacks, not just 'it is native'.

code

tsx · 33 lines
tsx
import {Animated, StyleSheet, useAnimatedValue} from 'react-native';
import {
  GestureDetector,
  GestureHandlerRootView,
  usePanGesture,
} from 'react-native-gesture-handler';

export default function DraggablePhoto() {
  const x = useAnimatedValue(0);

  const onUpdate = Animated.event(
    [{nativeEvent: {handlerData: {translationX: x}}}],
    {useNativeDriver: true},
  );

  const pan = usePanGesture({
    onUpdate,
    useAnimated: true,
  });

  return (
    <GestureHandlerRootView style={styles.root}>
      <GestureDetector gesture={pan}>
        <Animated.View style={[styles.photo, {transform: [{translateX: x}]}]} />
      </GestureDetector>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  root: {flex: 1},
  photo: {width: 200, height: 200, backgroundColor: 'gray'},
});

go deeper

for a junior

Recall that Gesture Handler recognises gestures in native code, while PanResponder runs in JavaScript.

for a middle

Explain both halves: native recognition, and UI-thread callbacks through Reanimated workletization with runOnJS defaulting to false.

for a senior

Show where smoothness is lost again: callbacks moved to JS, lost workletization through useCallback, and setState on every update.

for a principal

Set a team rule for gesture code: which interactions must stay entirely on the UI thread and how reviews catch callbacks that drift back to JavaScript.

## Two different pipelines A drag has two jobs: **recognising** the gesture (is this a pan, and who owns it?) and **reacting** to it (moving the photo). The two libraries put those jobs on different threads. | Step | PanResponder | Gesture Handler with Reanimated | |---|---|---| | Who owns the touch | responder negotiation in JavaScript | native recogniser, on the UI thread | | Activation and relations | JavaScript handlers | native state machine and orchestrator | | Per-move callback | JavaScript | worklet on the UI thread (by default) | | Visual update | React render or `Animated` driven from JS | shared value read by an animated style on the UI thread | With PanResponder, a long task on the JavaScript thread, such as rendering a big list, delays every one of those steps. With Gesture Handler and Reanimated, none of them has to wait for JavaScript. ## How recognition works natively - **iOS:** each gesture becomes a `UIGestureRecognizer` attached to the detector's view, and UIKit does most of the work, with the library adding the relations between gestures. - **Android:** the platform has no comparable system, so the library implements the recognisers and an **orchestrator**. `GestureHandlerRootView` intercepts touches; when a finger lands, every handler under it moves to the `BEGAN` state, and the first one to meet its activation criteria activates, unless it must wait for another to fail, while handlers that are not simultaneous with it are cancelled. Because the decision is native, a pan inside a pager is recognised, and the pager is held off, even if JavaScript is frozen. ## Where your callbacks run - With Reanimated installed, the hook API integrates with it by default, and **callbacks written inline in the config are automatically marked as worklets** by the worklets Babel plugin. - A callback defined elsewhere, or wrapped in `useCallback`, is **not** workletized automatically; it needs a `'worklet'` directive. - **`runOnJS`** defaults to `false`. Setting it to `true`, or to a shared value that becomes `true`, sends callbacks to the JavaScript thread. - **`disableReanimated: true`** turns the integration off for that gesture entirely. - Without Reanimated, callbacks run in JavaScript. ## What still depends on JavaScript Recognition staying native does not make everything immune to a busy JS thread: - A callback that runs on the JS thread still waits in the queue. - Anything that needs React, such as `setState` or navigation, runs in JavaScript by definition. - The hook API can also feed a React Native `Animated.event` from `onUpdate` when `useAnimated` is set; with `useNativeDriver: true`, that path moves the view natively as well, which a PanResponder cannot do. ## In the photo viewer Pinching a photo while the next page's image decodes in JavaScript is the classic test. With Gesture Handler recognising the pinch natively and a worklet writing the scale to a shared value, the photo keeps following the fingers; the new page's thumbnail may appear late, but the gesture does not stutter. ## Pitfalls - Assuming "Gesture Handler" alone makes a drag smooth while its callbacks call `setState` on every update. - Moving a callback into `useCallback` and silently losing workletization. - Mixing Reanimated and Animated in one detector, which the library does not support. ## Comparing the setups | Setup | Recognition | Callbacks | Effect of a busy JS thread | |---|---|---|---| | Gesture Handler, Reanimated installed, defaults | native | UI thread (worklets) | gesture and shared-value updates continue | | Same, with `runOnJS: true` | native | JavaScript thread | recognition continues, reactions queue | | Gesture Handler without Reanimated | native | JavaScript thread | recognition continues, reactions queue | | Hook API with a native-driven `Animated.event` in `onUpdate` | native | mapped natively | the mapped value keeps moving | | PanResponder | JavaScript | JavaScript thread | everything queues | Two conclusions follow: - **Native recognition alone** guarantees correct ownership, so a pager or a scroll view is held off properly even when JavaScript is frozen. - **Smooth visual tracking** additionally needs the reaction to avoid the JS thread, through worklets or the native-driven `Animated.event` path. An interviewer who asks whether Gesture Handler is faster usually wants this distinction: it is not faster JavaScript, it is less JavaScript on the path.

  • In Gesture Handler 3, which callbacks are automatically workletized, and which are not?
    Callbacks written inline inside the hook's config object are marked as worklets by the worklets Babel plugin. A function defined elsewhere and passed by reference, or one wrapped in `useCallback` or `useMemo`, is not; add a `'worklet'` directive at its top so it runs on the UI thread.
  • If a Gesture Handler pan sets runOnJS: true, what still runs natively?
    Recognition: touch tracking, activation criteria such as `minDistance`, and the relations with other gestures are still decided natively, so a pager can still be held off correctly. Only your callbacks move to the JavaScript thread, so the visual response now waits for JS like a PanResponder's would.

A lift with its own control panel versus one run by a receptionist: with the panel in the lift (native recognition and worklets), the ride continues while the receptionist is on a long call; with a receptionist-run lift (PanResponder), every floor waits for the call to end.

saying these in an interview costs you the question

  • Gesture Handler is smooth only because its JavaScript runs faster than PanResponder's
  • With runOnJS: true a Gesture Handler pan is recognised in JavaScript
  • Every gesture callback runs on the UI thread even without Reanimated
  • Wrapping a gesture callback in useCallback keeps it on the UI thread
  • Calling setState in every onUpdate keeps the drag independent of JS