In React Native, why does a collapsing header driven by onScroll and setState stutter, and how do you move it off the JS thread?
answer
- the callback lives on the JS thread
- a render per scroll frame
- scrollEventThrottle 0: at most once per frame
- Animated.event with useNativeDriver
- useAnimatedScrollHandler writes a shared value
basics
~20 sonScroll runs on the JS thread, so setState per event adds a render per frame that lags whenever JS is busy. Drive the header from the offset on the UI thread instead: Animated.event with useNativeDriver: true, or Reanimated's useAnimatedScrollHandler.
solid answer
~50 sThe scroll itself happens natively, but an `onScroll` callback is JavaScript: every event crosses to the JS thread, and a `setState` there adds a render and commit per frame. When that thread is also rendering episode rows or parsing a feed, header updates arrive late or in bursts, so the header trails the list. `scrollEventThrottle` cannot fix it: its default `0` already means no throttling (at most one event per frame), and larger values only make the header choppier. The fix is to keep the offset off the JS thread: map `nativeEvent.contentOffset.y` into an `Animated.Value` with `Animated.event(..., { useNativeDriver: true })` on an `Animated.ScrollView` or `Animated.FlatList` and interpolate `transform` and `opacity`, or write it to a shared value in Reanimated's `useAnimatedScrollHandler` and read it in `useAnimatedStyle`. Keep React state for discrete changes, such as showing the title once a threshold is crossed.
code
tsx · 45 linesimport { Animated, StyleSheet, Text, useAnimatedValue } from 'react-native';
const HEADER_HEIGHT = 240;
type Episode = { id: string; title: string };
export function PodcastProfile({ episodes }: { episodes: Episode[] }) {
const scrollY = useAnimatedValue(0);
const headerTranslateY = scrollY.interpolate({
inputRange: [0, HEADER_HEIGHT],
outputRange: [0, -HEADER_HEIGHT / 2],
extrapolate: 'clamp',
});
const artworkOpacity = scrollY.interpolate({
inputRange: [0, HEADER_HEIGHT / 2],
outputRange: [1, 0],
extrapolate: 'clamp',
});
return (
<>
<Animated.FlatList
data={episodes}
keyExtractor={(e) => e.id}
renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
contentContainerStyle={{ paddingTop: HEADER_HEIGHT }}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
})}
/>
<Animated.View style={[styles.header, { transform: [{ translateY: headerTranslateY }] }]}>
<Animated.View style={[styles.artwork, { opacity: artworkOpacity }]} />
<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: { textAlign: 'center', marginTop: 12, fontSize: 20 },
row: { padding: 16 },
});go deeper
Recall that onScroll is a JS callback, that setState per scroll event costs a render per frame, and that a native-driven Animated.event or Reanimated handler avoids it.
Explain the event path from native scroll to JS render, what scrollEventThrottle's default of 0 means today, and why the native-driven version must animate transform and opacity.
Diagnose a trailing header from a busy JS thread, pick native driver or Reanimated for the effect, and keep only threshold crossings in React state.
Set a team rule that scroll-linked visuals never go through React state, and weigh Animated against Reanimated as the default for new scroll effects.
## Where the scroll offset comes from In React Native, a `ScrollView` (and `FlatList`, which is built on it) is a native scroll view. The finger moves native content on the **UI thread**; your `onScroll` prop is a JavaScript function that receives an event shaped like `{ nativeEvent: { contentOffset: { x, y }, contentSize, layoutMeasurement, velocity, ... } }`. The docs describe `onScroll` as firing **at most once per frame** during scrolling. So a collapsing header on a podcast's profile screen can be driven from two places: from JavaScript, by reacting to each event, or from the offset itself, on the UI thread. ## Why the `setState` version stutters The naive version keeps the offset in React state: 1. The native scroll view emits a scroll event. 2. The event is delivered to the JS thread and your `onScroll` runs. 3. `setScrollY(y)` schedules a render of the screen (header, and often the list props too). 4. React renders, commits, and the header's new style is applied on the UI thread. Steps 2 to 4 compete with everything else on the JS thread: rendering newly visible episode rows, JSON parsing, analytics. When a frame's worth of JS work runs long, scroll events queue up, the header receives stale offsets and then jumps to catch up. The list keeps scrolling smoothly because it is native, which makes the lag obvious: the header visibly trails the content. ## What `scrollEventThrottle` does and does not fix | Setting | Effect | |---|---| | Default `0` | No throttling: `onScroll` fires at most once per frame | | Any value up to `16` | Also disables throttling, regardless of refresh rate | | A larger value, such as `100` | Events at most every 100 ms: less JS work, choppier JS-driven effects | Two details worth knowing: - Old advice was to always set `scrollEventThrottle={16}` to get continuous events. Since React Native 0.74 on the New Architecture that is no longer needed, and on 0.87 the New Architecture is the only architecture. - A `ScrollView` with `stickyHeaderIndices` forces a throttle of `1` internally, because its sticky headers are driven from the scroll offset. Throttling therefore trades smoothness for less JS work; it never removes the JS thread from the path. ## Fix 1: a native-driven offset with `Animated` Map the offset straight into an `Animated.Value` and let the native side apply it: - Use `Animated.ScrollView` or `Animated.FlatList`, and pass `onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true })}`. - Derive header styles with `scrollY.interpolate({ inputRange, outputRange, extrapolate: 'clamp' })`. - Animate **`transform` and `opacity`**, not `height` or `top`: on the stable release channel the native driver handles only non-layout properties. Animating layout props with the native driver arrived with the experimental Shared Animation Backend (0.85.1+), which is not on by default. - An optional `listener` in the `Animated.event` config still runs on the JS thread asynchronously, so do not rebuild the setState problem inside it. ## Fix 2: Reanimated's scroll handler With Reanimated 4, `useAnimatedScrollHandler((e) => { scrollY.value = e.contentOffset.y; })` returns a handler you pass to `onScroll` of Reanimated's `Animated.ScrollView` or `Animated.FlatList`. The handler is a worklet on the UI runtime, the offset lives in a shared value, and `useAnimatedStyle` with `interpolate` turns it into header styles. This route also lets you run arbitrary logic per event on the UI thread, which the declarative `Animated.event` mapping cannot. ## What still belongs in React state Some changes are genuinely discrete: showing the show title in the navigation bar once the artwork has scrolled away, or toggling a "back to top" button. Keep those in React state, but update it only when the offset **crosses** the threshold, not on every event. With Reanimated, detect the crossing in a worklet and call `scheduleOnRN` once. ## The three options side by side | Approach | Where the header update runs | Cost per scroll frame on the JS thread | |---|---|---| | `onScroll` plus `setState` | JS thread, then commit | A full render | | `Animated.event` with native driver | UI thread | None, unless a `listener` does work | | `useAnimatedScrollHandler` | UI runtime (worklet) | None |
- Would setting scrollEventThrottle={100} fix the stuttering setState header?No. It limits events to one per 100 ms, which cuts JS work but makes a JS-driven header visibly step between positions. The default `0` already delivers at most one event per frame; the stutter comes from rendering on a busy JS thread, which only moving the offset off that thread removes.
- Why can't the native-driven header shrink its height instead of translating?On the stable release channel the native driver animates only non-layout properties such as `transform` and `opacity`; `height` needs a layout pass. React Native 0.85.1 introduced an experimental animation backend that lifts this, but it is opt-in. The usual design translates the header and lets it slide under a fixed bar.
- How do you show the show's title in the navigation bar once the artwork scrolls away, without re-rendering every frame?Treat it as a threshold, not a stream. With Reanimated, compare the offset in the scroll worklet and call `scheduleOnRN(setTitleVisible, true)` only when it crosses the threshold. With Animated, a JS listener must at least compare and set state only on change.
The setState header is a stage light operated by someone reading a live text feed of where the actor is: when the operator is distracted, the spotlight lags. The native-driven header is a light bolted to the actor.
saying these in an interview costs you the question
- onScroll handlers run on the UI thread, so setState inside them is instant.
- You must set scrollEventThrottle={16} or onScroll fires only once.
- A higher scrollEventThrottle makes a JS-driven header smoother.
- The native driver can shrink the header's height on the stable release channel.
- Animated.event's listener runs on the UI thread with the native driver.
- A plain ScrollView accepts a native-driven Animated.event without the Animated wrapper.