High-Performance Lists & Animations with Reanimated and FlashList
Mobile15 min read

High-Performance Lists & Animations with Reanimated and FlashList

Build buttery-smooth React Native apps by pairing Shopify FlashList with Reanimated 3. Learn recycling, the UI thread, worklets, gesture-driven and scroll-linked animations, and how to hunt down jank.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Mar 18, 2026
#React Native#Reanimated#FlashList#Performance#Animation#Mobile#Expo

FlashList and Reanimated are the two libraries that solve the biggest perceived-quality problems in React Native at the architectural level: FlashList (by Shopify) replaces FlatList with a recycling-based list that reuses mounted cells instead of creating and destroying them, and Reanimated 3 runs animations as worklets directly on the UI thread so they never wait on a busy JavaScript thread. Both exist because of the frame budget: at 60fps a frame must complete in roughly 16.6ms, and on 120Hz ProMotion displays the budget drops to about 8.3ms; missing it produces dropped frames, better known as jank. Used together, they keep long lists scrolling smoothly and gestures responding instantly even while the JS thread is busy. This guide explains why FlatList blanks out under fast scrolling, how FlashList recycling works, Reanimated fundamentals, gesture-driven and scroll-linked animations, layout animations, profiling, and how to combine both libraries in one screen.

Why Does FlatList Struggle with Large Lists?

FlatList is virtualized, meaning it only renders items near the viewport instead of the entire dataset. That is a real improvement over ScrollView, which mounts every child up front. But FlatList has a design flaw that shows up under pressure: as you scroll, it mounts brand-new component instances for incoming rows and unmounts the ones that leave. Every mount runs the full React lifecycle, allocates native views, and triggers layout. Under fast scrolling the JS thread cannot keep up with creating and destroying views, so FlatList shows blank cells until content catches up.

On top of that, FlatList does a lot of bookkeeping on the JavaScript thread: measuring items, computing offsets, and reconciling the window of visible rows. With complex cells, tall images, or nested lists, the JS thread becomes the bottleneck. You can tune props like windowSize, maxToRenderPerBatch, initialNumToRender, and removeClippedSubviews, but you are fighting the architecture rather than working with it.

tsx
// A typical FlatList that will blank out under fast scrolling
import { FlatList } from 'react-native';

<FlatList
  data={products}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <ProductCard product={item} />}
  // Tuning props: helpful, but you are patching an architectural limit
  windowSize={10}
  maxToRenderPerBatch={8}
  initialNumToRender={8}
  removeClippedSubviews
/>;

How Does FlashList Recycling Work?

FlashList, built by Shopify, takes a different approach borrowed from native list systems like UICollectionView and RecyclerView. Instead of unmounting rows that leave the screen and mounting new ones, it recycles a small pool of view instances. When a row scrolls off, its view is not destroyed; it is re-bound with the data of the next incoming row. Because the expensive native view already exists, only the props change. This keeps memory flat and drastically reduces the work the JS thread performs during scrolling.

The estimatedItemSize Prop

The one prop that trips everyone up first is estimatedItemSize. Because FlashList recycles views, it needs an approximate row height (or width for horizontal lists) up front to calculate how many views to pre-render and how to size its scroll content before real measurements arrive. It does not need to be perfect: measure a typical row in pixels and pass that number. A good estimate improves initial render and scroll accuracy; a wildly wrong one causes layout thrash.

tsx
import { FlashList } from '@shopify/flash-list';

function ProductList({ products }: { products: Product[] }) {
  return (
    <FlashList
      data={products}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <ProductCard product={item} />}
      // Average measured row height in px. Not a guess to skip; measure it.
      estimatedItemSize={96}
    />
  );
}

Migrating from FlatList

Migration is mostly mechanical. FlashList mirrors the FlatList API, so most props carry over unchanged: data, renderItem, keyExtractor, onEndReached, ListHeaderComponent, ListFooterComponent, and ListEmptyComponent all work as expected. You swap the import, add estimatedItemSize, and remove the FlatList tuning props that no longer apply.

tsx
// Before
import { FlatList } from 'react-native';

<FlatList
  data={products}
  renderItem={({ item }) => <ProductCard product={item} />}
  keyExtractor={(item) => item.id}
  windowSize={10}
  maxToRenderPerBatch={8}
/>;

// After
import { FlashList } from '@shopify/flash-list';

<FlashList
  data={products}
  renderItem={({ item }) => <ProductCard product={item} />}
  keyExtractor={(item) => item.id}
  estimatedItemSize={96}
/>;

Gotchas You Will Hit

Common FlashList pitfalls and how to avoid them:

  • Local state in recycled cells: because views are reused, a cell that keeps internal state (an expanded/collapsed toggle, an animation value) can show the wrong state for a new item. Derive UI from the item prop, or reset state when the item id changes.
  • Different row types: heterogeneous rows recycle poorly if they share one pool. Use getItemType to give distinct layouts their own recycling buckets.
  • Wrong estimatedItemSize: too far off and you get blank space or measurement jumps. Measure a real row rather than guessing.
  • No height on the container: FlashList must have a bounded height. Wrap it in a flex:1 parent, never in a plain ScrollView.
  • Inline anonymous functions: keep renderItem stable and memoize cell components with React.memo so recycling actually skips re-renders.
tsx
// getItemType keeps different layouts in separate recycle pools
<FlashList
  data={feed}
  estimatedItemSize={120}
  getItemType={(item) => item.kind} // 'ad' | 'post' | 'banner'
  renderItem={({ item }) => {
    switch (item.kind) {
      case 'ad':
        return <AdCard ad={item} />;
      case 'banner':
        return <BannerCard banner={item} />;
      default:
        return <PostCard post={item} />;
    }
  }}
/>;

Reanimated 3 Fundamentals

React Native runs your JavaScript on one thread and the native UI on another. In the classic Animated API, driving an animation from JS means serializing values across the bridge every frame, and if the JS thread is busy (rendering a list, parsing a response) the animation stutters. Reanimated solves this by running animation logic directly on the UI thread through a mechanism called worklets.

Worklets and the UI Thread

A worklet is a JavaScript function that Reanimated extracts and runs on the UI thread in a separate runtime. You mark one with the "worklet" directive, though most of the time the Reanimated hooks turn your callbacks into worklets automatically. Because the code runs on the UI thread, it can read and write animated values and update styles at native frame rate without ever touching the JS thread. To hop back to JS from a worklet (for example to update React state), you use runOnJS.

tsx
import { runOnJS } from 'react-native-reanimated';

function logProgress(value: number) {
  // Runs on the JS thread
  console.log('progress', value);
}

const worklet = () => {
  'worklet';
  // This body executes on the UI thread
  const next = 0.5;
  runOnJS(logProgress)(next); // hop back to JS safely
};

useSharedValue and useAnimatedStyle

A shared value is a piece of state that lives on both threads and can be mutated from either. It is the animation primitive in Reanimated: you write to its .value and the UI thread reacts. useAnimatedStyle takes a worklet that reads shared values and returns a style object; Reanimated re-runs that worklet on the UI thread whenever a dependency changes and applies the result to the view without a React re-render.

tsx
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withSpring,
} from 'react-native-reanimated';

function FadeInBox() {
  const opacity = useSharedValue(0);
  const scale = useSharedValue(0.8);

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    transform: [{ scale: scale.value }],
  }));

  const reveal = () => {
    // withTiming: duration + easing curve
    opacity.value = withTiming(1, { duration: 300 });
    // withSpring: physics-based, no fixed duration
    scale.value = withSpring(1, { damping: 12, stiffness: 120 });
  };

  return (
    <Animated.View style={[styles.box, animatedStyle]} onTouchEnd={reveal} />
  );
}

The two workhorse animation helpers are withTiming and withSpring. withTiming interpolates toward a target over a fixed duration using an easing curve, ideal for opacity fades and deterministic transitions. withSpring uses a physical spring model parameterized by damping, stiffness, and mass, giving natural, interruptible motion that feels great for gestures and interactive UI. Both return a value you assign to a shared value; Reanimated drives the animation on the UI thread from there.

The mental model that unlocks Reanimated: shared values are the state, worklets are the logic that runs on the UI thread, and useAnimatedStyle is the bridge from that state to what the user sees, all without waking up the JS thread.

UI Thread vs JS Thread Animations

This is the core reason Reanimated exists. Consider a spinner rendered while your app fetches and parses a large payload. With a JS-driven animation, the JSON parse blocks the JS thread and the spinner freezes precisely when the user is waiting and most likely to notice. With Reanimated, the spinner animation lives on the UI thread and keeps rotating smoothly no matter how busy JS is. The classic Animated API offers useNativeDriver: true for a subset of properties (transforms and opacity), but it cannot drive layout properties and cannot run arbitrary logic per frame. Reanimated runs your whole animation worklet natively, which is why gesture and scroll-linked effects stay smooth even under heavy JS load.

Gesture-Driven Animations

Reanimated pairs naturally with react-native-gesture-handler, whose gesture callbacks also run as worklets on the UI thread. That means a drag can update a shared value directly during the gesture with zero JS round-trips, so the element tracks the finger perfectly. Here is a draggable card that snaps back with a spring when released.

tsx
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from 'react-native-reanimated';

function DraggableCard() {
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const pan = Gesture.Pan()
    .onChange((event) => {
      // Runs on the UI thread; tracks the finger with no bridge hop
      translateX.value += event.changeX;
      translateY.value += event.changeY;
    })
    .onEnd(() => {
      // Snap back with spring physics
      translateX.value = withSpring(0);
      translateY.value = withSpring(0);
    });

  const style = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { translateY: translateY.value },
    ],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.card, style]} />
    </GestureDetector>
  );
}

Scroll-Linked Animations

A collapsing or fading header driven by scroll position is one of the most requested UI patterns, and one of the jankiest when done on the JS thread. useAnimatedScrollHandler gives you a worklet that receives scroll events on the UI thread, so you can map scroll offset to a shared value and derive styles with interpolate, all at native frame rate.

tsx
import Animated, {
  useSharedValue,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  interpolate,
  Extrapolation,
} from 'react-native-reanimated';

const HEADER_MAX = 200;
const HEADER_MIN = 80;

function ScreenWithAnimatedHeader() {
  const scrollY = useSharedValue(0);

  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollY.value = event.contentOffset.y; // UI thread
  });

  const headerStyle = useAnimatedStyle(() => {
    const height = interpolate(
      scrollY.value,
      [0, HEADER_MAX - HEADER_MIN],
      [HEADER_MAX, HEADER_MIN],
      Extrapolation.CLAMP
    );
    const opacity = interpolate(
      scrollY.value,
      [0, HEADER_MAX - HEADER_MIN],
      [1, 0],
      Extrapolation.CLAMP
    );
    return { height, opacity };
  });

  return (
    <>
      <Animated.View style={[styles.header, headerStyle]} />
      <Animated.ScrollView
        onScroll={scrollHandler}
        scrollEventThrottle={16}
      >
        {/* content */}
      </Animated.ScrollView>
    </>
  );
}

Layout Animations

Reanimated ships built-in layout animations for elements entering, exiting, and rearranging. Instead of manually animating opacity and position, you attach an entering or exiting preset (or build custom ones), and Reanimated animates the transition automatically on the UI thread. These are perfect for list insertions and removals, expanding sections, and toast notifications.

tsx
import Animated, {
  FadeIn,
  FadeOut,
  Layout,
  SlideInRight,
} from 'react-native-reanimated';

function NotificationItem({ visible }: { visible: boolean }) {
  if (!visible) return null;
  return (
    <Animated.View
      entering={SlideInRight.duration(250)}
      exiting={FadeOut.duration(200)}
      layout={Layout.springify()} // animate position when siblings change
      style={styles.toast}
    >
      {/* content */}
    </Animated.View>
  );
}

How Do You Profile and Find Jank?

When something feels janky, the first job is to find out which thread is overloaded. React Native gives you two frame-rate readouts in the dev menu Perf Monitor: the UI thread and the JS thread. If the JS FPS drops during scrolling, your bottleneck is JS-side work (rendering, state updates, heavy computation). If the UI FPS drops, you have too much native work per frame (overdraw, large images, expensive shadows). React DevTools Profiler and Flipper (or the newer Hermes/React Native DevTools) help you pin down slow renders.

The usual suspects behind list and animation jank:

  • Non-memoized cells: cells re-render on every parent update. Wrap them in React.memo and keep renderItem referentially stable.
  • Heavy work in render: sorting, filtering, or date formatting inside renderItem runs for every row. Precompute it or memoize with useMemo.
  • Large unoptimized images: decoding full-resolution images blocks the UI thread. Use properly sized images and a caching image component such as expo-image or FastImage.
  • Animating layout props on the JS thread: animating width/height/margin via setState triggers layout and re-render each frame. Prefer transform and opacity via Reanimated on the UI thread.
  • runOnJS in a hot path: calling back into JS every frame from a gesture or scroll worklet reintroduces the bottleneck you were avoiding. Keep per-frame logic inside the worklet.
  • Inline styles and arrow functions in JSX: they allocate new references every render and defeat memoization.
tsx
import React from 'react';

// Memoized cell: recycling + memo means near-zero re-render work
type Props = { product: Product };

const ProductCard = React.memo(function ProductCard({ product }: Props) {
  return (
    <Animated.View style={styles.card}>
      {/* render product */}
    </Animated.View>
  );
});

// Stable renderItem defined outside the component render scope
const renderItem = ({ item }: { item: Product }) => (
  <ProductCard product={item} />
);

How Do FlashList and Reanimated Work Together?

The two libraries compose cleanly, but recycling changes the rules for animation. Because FlashList reuses view instances, a shared value tied to a recycled cell will carry over to the next item unless you reset it. Two patterns keep this correct. First, derive animation state from the item prop whenever possible so a re-bound cell naturally reflects new data. Second, if a cell holds its own shared value, reset it when the item id changes so the recycled view starts fresh.

tsx
import { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated';

const Row = React.memo(function Row({ item }: { item: Product }) {
  const progress = useSharedValue(0);

  // Reset on recycle: when the bound item changes, restart the animation
  useEffect(() => {
    progress.value = 0;
    progress.value = withTiming(1, { duration: 250 });
  }, [item.id]);

  const style = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [{ translateY: (1 - progress.value) * 12 }],
  }));

  return (
    <Animated.View style={style}>
      {/* row content */}
    </Animated.View>
  );
});

For scroll-linked effects over a FlashList, FlashList exposes a Reanimated-friendly scroll handler. Wire useAnimatedScrollHandler to its onScroll and drive an animated header exactly as you would with an Animated.ScrollView, keeping the entire pipeline (recycled rows plus header animation) on the UI thread. The payoff of combining them is significant: FlashList removes the JS-thread cost of scrolling while Reanimated removes the JS-thread cost of animating, so both can run flat-out without competing for the same frame budget.

tsx
import { FlashList } from '@shopify/flash-list';
import Animated, {
  useSharedValue,
  useAnimatedScrollHandler,
} from 'react-native-reanimated';

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<Product>);

function Feed({ products }: { products: Product[] }) {
  const scrollY = useSharedValue(0);
  const scrollHandler = useAnimatedScrollHandler((event) => {
    scrollY.value = event.contentOffset.y;
  });

  return (
    <AnimatedFlashList
      data={products}
      estimatedItemSize={96}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Row item={item} />}
      onScroll={scrollHandler}
      scrollEventThrottle={16}
    />
  );
}

Key Takeaways

What to remember when building high-performance React Native UIs:

  • FlatList remounts rows as they scroll; FlashList recycles a small pool of views, keeping memory flat and the JS thread free.
  • Always set a realistic estimatedItemSize (measure a row), use getItemType for heterogeneous lists, and never nest FlashList in a plain ScrollView.
  • Reanimated runs animation logic in worklets on the UI thread, so animations stay smooth even when the JS thread is busy.
  • Shared values hold state, useAnimatedStyle maps it to styles, and withTiming/withSpring drive it, all without React re-renders.
  • Gesture Handler and useAnimatedScrollHandler run their callbacks as worklets, enabling finger-tracking and scroll-linked animations at native frame rate.
  • Prefer animating transform and opacity over layout properties, and avoid runOnJS in per-frame hot paths.
  • Reset or derive per-cell animation state on item id changes so recycled FlashList views never show the wrong state.
  • Profile with the Perf Monitor: JS FPS drops mean JS-side work, UI FPS drops mean native/rendering work. Fix the thread that is actually overloaded.

Share this article

Related Articles