skip to content

In a React Native PanResponder, why can reading evt.nativeEvent or gestureState inside a setState updater or setTimeout give null or zeros?

level: middleimportance: nice to knowfreq 18%

answer

  1. reused objects, not snapshots
  2. responder events are still pooled
  3. nativeEvent nulled after dispatch
  4. gestureState reset after Release or Terminate
  5. copy the numbers out first

basics

~20 s

Both objects are reused. React Native's responder events are pooled, so nativeEvent is nulled once the handler returns, and PanResponder mutates one gestureState in place and resets it to zeros after release or terminate. Copy the numbers out synchronously.

solid answer

~50 s

Two objects are recycled behind your back. First, the event: React Native's renderer still dispatches responder events as **pooled** synthetic events, so after your handler returns the event is released and its `nativeEvent` becomes `null`. Code that runs later, such as a `setState` updater React applies during the next render, a `setTimeout` or a promise callback, reads `evt.nativeEvent.locationX` from a dead object and throws. Second, `gestureState`: each `PanResponder.create()` owns one object that is updated in place on every move and reset to zeros right after `onPanResponderRelease` or `onPanResponderTerminate` returns. Reading it later gives the next gesture's values or zeros. The fix is to destructure the numbers you need synchronously, such as `const {locationX, locationY} = evt.nativeEvent` or `const {vx, vy} = gestureState`, before scheduling anything. `evt.persist()` keeps an event out of the pool, but copying fields is simpler.

code

tsx · 31 lines
tsx
import {useRef, useState} from 'react';
import {PanResponder, View} from 'react-native';

type Point = {x: number; y: number};

export function useStrokeRecorder() {
  const [points, setPoints] = useState<Point[]>([]);
  const lastFling = useRef({vx: 0, vy: 0});

  const pan = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: evt => {
        // Broken: setPoints(prev => [...prev, {x: evt.nativeEvent.locationX, ...}])
        const {locationX, locationY} = evt.nativeEvent;
        setPoints(prev => [...prev, {x: locationX, y: locationY}]);
      },
      onPanResponderRelease: (_evt, gestureState) => {
        const {vx, vy} = gestureState;
        lastFling.current = {vx, vy};
      },
    }),
  ).current;

  return {points, lastFling, panHandlers: pan.panHandlers};
}

export function Board() {
  const {panHandlers} = useStrokeRecorder();
  return <View style={{flex: 1}} {...panHandlers} />;
}

go deeper

for a junior

Recall the rule: copy locationX, locationY or dx out of the callback arguments before using them anywhere asynchronous.

for a middle

Explain both mechanisms: pooled events nulled after dispatch and one gestureState mutated in place and reset after release or terminate.

for a senior

Recognise the symptom in a crash report: an intermittent null read inside a state updater that appears only under load, and fix it at the source.

for a principal

Decide whether a lint rule or a shared gesture hook should enforce copying event data, so the whole team stops rediscovering this bug.

## Two objects that are reused A PanResponder callback receives two arguments: the **touch event** and **`gestureState`**. Neither belongs to you after the callback returns. That is harmless when you read them synchronously, and a source of intermittent crashes or wrong values when you read them from code that runs later. ## Event pooling in React Native's renderer React Native delivers responder events through the renderer's older event-plugin path, which **pools** synthetic events: 1. The renderer takes an event object from a small pool and fills it with the touch data. 2. It calls the handlers for that event. 3. Unless the event was persisted, it **releases** the object back to the pool, and releasing it sets `nativeEvent` to `null` and clears the other fields (in development builds, reading them logs a warning). In development, touching a released event logs a warning that the synthetic event is reused for performance reasons and suggests `event.persist()`. React DOM removed event pooling in React 17, which is why developers coming from the web often do not expect this; the React Native 0.87 renderer still releases responder events after dispatch. ## gestureState is one mutable object `PanResponder.create()` allocates **one** `gestureState` object and closes over it: - On every move, `dx`, `dy`, `moveX`, `moveY`, `vx`, `vy` and `numberActiveTouches` are **overwritten in place**. - After `onPanResponderRelease` or `onPanResponderTerminate` returns, PanResponder **re-initialises** it: `dx`, `dy`, `vx`, `vy` and the other fields go back to `0`. - The same object is also reset when a new first finger lands, ready for the next gesture. So a reference to `gestureState` kept past the callback is a live view of whatever gesture comes next, not a snapshot. ## Where "later" sneaks in | Code | When it runs | Safe to read the event or gestureState? | |---|---|---| | the callback body | during dispatch | yes | | a `setState` updater function | when React processes the update; React does not promise it runs before the handler returns | no | | `setTimeout`, `requestAnimationFrame`, a promise | after the handler returns | no | | an `Animated` completion callback | when the animation ends | no | The updater case is the nasty one. Sometimes React runs an updater eagerly and the code works; at other times it runs during the next render, after the event was released, and throws. The result is a crash that appears only under load. ## The fixes 1. **Copy primitives out first.** `const {locationX, locationY} = evt.nativeEvent;` then use `locationX` inside the updater. 2. **Snapshot gestureState.** `const {dx, dy, vx, vy} = gestureState;` before starting a fling or scheduling work. 3. **Read release values inside the release callback.** PanResponder calls `onPanResponderRelease` **before** it resets `gestureState`, so `gestureState.vx` is valid there, and only there. 4. **Use `evt.persist()` sparingly.** It removes that one event from the pool so you can pass it to async code, but copying the handful of numbers you need is clearer and cheaper. ## In a kids' sketch app A common version of the bug: the move handler calls `setPoints(prev => [...prev, {x: evt.nativeEvent.locationX, y: evt.nativeEvent.locationY}])`. It works when a child draws slowly on a fast phone. On a busy screen, React defers the updater, the event has been released, and the app crashes with a read of `locationX` on `null`. Destructuring the coordinates on the line before the `setPoints` call removes the crash entirely. ## What to say in an interview - Name both objects and why each is recycled. - Point out that release-time values such as velocity must be read inside `onPanResponderRelease`. - Mention that this differs from React DOM since React 17, which is exactly why it surprises people. ## How to spot it in a codebase Search gesture code for three patterns: - `evt.nativeEvent` or `e.nativeEvent` used **inside** an arrow function passed to `setState`, `setTimeout`, `requestAnimationFrame` or `.then`. - `gestureState` stored in a ref or state variable, rather than its individual numbers. - A fling or snap animation started from `onPanResponderRelease` whose completion callback reads `gestureState`. Each one is a latent bug even if it has never crashed, because whether it fails depends on timing that changes with device speed and screen load.

  • Is it safe to read gestureState.vx inside onPanResponderRelease to fling a sticker in React Native?
    Yes, synchronously. PanResponder calls your release callback first and resets the gesture state only after it returns, so `vx` and `vy` hold the final velocity during the call. Copy them into locals before starting the animation or awaiting anything, because a completion callback or a later tick sees the reset values.
  • When is evt.persist() the right fix in a React Native responder handler?
    When you genuinely need the whole event object in asynchronous code, for example to pass it to a helper that runs after an await. `persist()` stops the renderer from releasing that event back to the pool. For the usual case, a few coordinates or timestamps, destructuring them synchronously is cheaper and makes the dependency explicit.

saying these in an interview costs you the question

  • React Native stopped pooling responder events when React DOM did in React 17
  • PanResponder passes a fresh gestureState object to every callback
  • A setState updater always runs before the event handler returns
  • gestureState keeps its final dx and vx after release until the next touch
  • Every responder callback must call evt.persist() to be safe