skip to content

In Reanimated 4, how does useAnimatedScrollHandler drive a collapsing podcast header, and which scroll events can it handle?

level: middleimportance: should knowfreq 36%

answer

  1. worklets per scroll event
  2. onScroll, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd
  3. always passed to onScroll
  4. Animated.ScrollView, not ScrollView
  5. a context object shared across events

basics

~20 s

useAnimatedScrollHandler returns a handler whose worklets run on the UI runtime for onScroll, onBeginDrag, onEndDrag, onMomentumBegin and onMomentumEnd. Pass it to onScroll of Reanimated's Animated.ScrollView or Animated.FlatList, write the offset to a shared value, and interpolate the header.

solid answer

~40 s

`useAnimatedScrollHandler` takes either one worklet, treated as the `onScroll` handler, or an object with `onScroll`, `onBeginDrag`, `onEndDrag`, `onMomentumBegin` and `onMomentumEnd`. Each worklet receives the scroll event (`event.contentOffset.y` and the usual `ScrollView` fields) and a `context` object that persists between events and is shared by all the handlers. The returned handler always goes to the `onScroll` prop, even when it handles drag or momentum events, and only works on Animated-wrapped scrollables such as Reanimated's `Animated.ScrollView` or `Animated.FlatList`. For the header, write `scrollY.value = event.contentOffset.y` and derive `translateY` and `opacity` in `useAnimatedStyle` with `interpolate(..., Extrapolation.CLAMP)`. If you only need the offset, `useScrollOffset(animatedRef)` returns it as a shared value; it was called `useScrollViewOffset` before Reanimated 4.

code

tsx · 56 lines
tsx
import { StyleSheet, Text } from 'react-native';
import Animated, {
  Extrapolation,
  interpolate,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';

const HEADER_HEIGHT = 240;
const COLLAPSED_HEIGHT = 88;
const RANGE = HEADER_HEIGHT - COLLAPSED_HEIGHT;

type Episode = { id: string; title: string };

export function ShowScreen({ episodes }: { episodes: Episode[] }) {
  const scrollY = useSharedValue(0);

  const onScroll = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
    },
  });

  const headerStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: interpolate(scrollY.value, [0, RANGE], [0, -RANGE], Extrapolation.CLAMP) },
    ],
  }));
  const artworkStyle = useAnimatedStyle(() => ({
    opacity: interpolate(scrollY.value, [0, RANGE / 2], [1, 0], Extrapolation.CLAMP),
  }));

  return (
    <>
      <Animated.FlatList
        data={episodes}
        keyExtractor={(e) => e.id}
        renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
        onScroll={onScroll}
        contentContainerStyle={{ paddingTop: HEADER_HEIGHT }}
      />
      <Animated.View style={[styles.header, headerStyle]}>
        <Animated.View style={[styles.artwork, artworkStyle]} />
        <Text style={styles.title}>Show title</Text>
      </Animated.View>
    </>
  );
}

const styles = StyleSheet.create({
  header: { position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_HEIGHT, backgroundColor: 'white' },
  artwork: { width: 120, height: 120, alignSelf: 'center', marginTop: 24, backgroundColor: 'gray' },
  title: { position: 'absolute', bottom: 16, alignSelf: 'center', fontSize: 20 },
  row: { padding: 16 },
});

go deeper

for a junior

Recall that useAnimatedScrollHandler returns a handler for onScroll, that it needs Reanimated's Animated.ScrollView or Animated.FlatList, and that it writes the offset to a shared value.

for a middle

Explain the five event keys, the shared context object, clamped interpolation for the header, and when useScrollOffset is the simpler tool.

for a senior

Add snapping on drag or momentum end, cross to React only at thresholds, and know the New Architecture flicker fixes Reanimated documents.

for a principal

Decide where scroll-linked effects live in the codebase, Reanimated or core Animated, so screens share one pattern and one set of performance flags.

## What the hook gives you `useAnimatedScrollHandler` is Reanimated's way to react to scrolling **on the UI runtime**. It returns an event handler object that you attach to a scrollable. Each time the native scroll view dispatches an event, the matching worklet runs on the UI thread, without a trip to the JS thread and without a React render. It accepts two shapes: - **A single worklet** `(event, context) => { ... }`, treated as the `onScroll` handler. - **An object of worklets** keyed by event name: `onScroll`, `onBeginDrag`, `onEndDrag`, `onMomentumBegin`, `onMomentumEnd`. Each worklet receives: 1. `event`, the scroll payload, with the same structure as React Native's `ScrollView` events: `contentOffset`, `contentSize`, `layoutMeasurement` and so on. 2. `context`, a plain object that **persists between events**. When you pass several handlers, they share the same `context`, so `onBeginDrag` can record a starting offset that `onScroll` later reads. ## Wiring it up correctly Two rules trip people up: - **Always pass it to `onScroll`.** Even a handler that only defines `onBeginDrag` and `onMomentumEnd` is passed as `onScroll={handler}`; Reanimated registers the other events itself. - **Use an Animated-wrapped scrollable.** The handler is triggered only on components wrapped with Reanimated's `Animated`, such as `Animated.ScrollView` or `Animated.FlatList` from `react-native-reanimated`, not on a plain `ScrollView` from `react-native`. The same handler object may be passed to several scrollables; its worklets then run for events from any of them. ## Building the collapsing header For a podcast's show page with a large artwork header over the episode list: 1. Keep the offset in a shared value: `scrollY.value = event.contentOffset.y`. 2. Derive the header's `translateY` with `interpolate(scrollY.value, [0, RANGE], [0, -RANGE], Extrapolation.CLAMP)`. 3. Fade the artwork with a shorter input range for `opacity`. 4. Give the list `contentContainerStyle={{ paddingTop: HEADER_HEIGHT }}` so the first episode starts below the header. `Extrapolation.CLAMP` matters: without it, iOS's bounce at the top produces negative offsets and the header would slide *down* past its resting position. ## Using the other events | Event key | Fires when | Typical use in a header | |---|---|---| | `onBeginDrag` | The finger starts dragging | Store the starting offset in `context` | | `onScroll` | Each scroll frame | Update the shared value | | `onEndDrag` | The finger lifts | Decide where a half-collapsed header should settle | | `onMomentumBegin` | Glide after release starts | Rarely needed for headers | | `onMomentumEnd` | Glide stops | Snap a partially hidden header open or closed | On the web only `onScroll` is supported, because there are no native drag and momentum events. ## When you only need the offset `useScrollOffset(animatedRef)` returns a shared value holding the current offset of the scrollable that `animatedRef` (from `useAnimatedRef`) is attached to. It detects horizontal versus vertical automatically and works with `ScrollView`, `FlatList` and `FlashList`. Reanimated 4 renamed it from `useScrollViewOffset`, which remains only as a deprecated alias. Reach for `useAnimatedScrollHandler` when you need per-event logic or drag and momentum events. ## Crossing back to React The handler is a worklet, so it cannot call `setState` directly. For a discrete change, such as revealing a compact title bar, compare against a threshold in the worklet and call `scheduleOnRN` only when the threshold is crossed. ## Common mistakes - **Passing the handler to a plain `ScrollView`.** Nothing fires; use Reanimated's `Animated.ScrollView` or `Animated.FlatList`. - **Splitting the handler across props.** Drag and momentum worklets still go in the one object passed to `onScroll`. - **Interpolating without clamping.** Bounce offsets push the header past its resting or collapsed position. - **Calling React from the worklet.** `setState` inside the handler throws; cross with `scheduleOnRN` at a threshold. - **Animating `height` instead of `transform`.** Layout props force a layout pass every frame; translating the header is cheaper. ## Compared with `Animated.event` Core `Animated.event` with `useNativeDriver: true` also keeps the offset off the JS thread, but it is a declarative mapping: you can interpolate the value, not run logic per event. `useAnimatedScrollHandler` runs code, so direction tracking, snapping and thresholds are straightforward.

  • How would you snap a half-collapsed header fully open or closed when scrolling stops?
    Handle `onEndDrag` and `onMomentumEnd` in the same `useAnimatedScrollHandler`. If the offset rests inside the collapse range, scroll to the nearer edge with Reanimated's `scrollTo` on an animated ref, or, when the header has its own shared value, animate that with `withTiming`. Use `context` to remember state between the events.
  • Your sticky header built with useAnimatedScrollHandler flickers while scrolling on the New Architecture. Where do you look?
    Reanimated's performance guide lists this case: on React Native 0.81 or newer, enable the `preventShadowTreeCommitExhaustion` React Native feature flag together with Reanimated's `DISABLE_COMMIT_PAUSING_MECHANISM` static flag. Also check the effect animates `transform` rather than layout props, and test a release build.

saying these in an interview costs you the question

  • The drag and momentum handlers must be passed to onScrollBeginDrag and onMomentumScrollEnd props.
  • useAnimatedScrollHandler works on a plain ScrollView imported from react-native.
  • Each scroll event gets a fresh context object, so nothing carries between events.
  • The scroll worklet can call setState directly because it receives a React event.
  • useScrollViewOffset is still the current name in Reanimated 4.
  • All five scroll events are delivered on the web as well.