◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-02Intermediate

When Your FlatList Starts Stuttering: Migrating Rork Apps to FlashList v2

When your Rork app's long lists start feeling sluggish, migrating to FlashList v2 makes scrolling dramatically smoother. Here is the practical migration path, taking advantage of v2's removal of estimatedItemSize.

FlashList5FlatList10Performance25React Native239Rork585

Past 300 items, scrolling suddenly feels broken

When I shipped the first version of a journaling app generated with Rork, scrolling felt buttery during testing with sample data. Once real users hit 200 or 300 entries, however, scrolling visibly stuttered, and the timeline with image cards started flashing white placeholders for a frame as you swiped down.

My first guess was image caching. The actual culprit was that FlatList had hit its rendering ceiling. Swapping the same data into FlashList v2 made scrolling smooth again, and memory usage dropped noticeably. Below is how I think about moving a Rork-generated app to FlashList v2, along with the places where I got stuck.

Why FlatList slows down isn't visible from your code

FlatList is React Native's built-in list, but under the hood it inherits from VirtualizedList and works by unmounting items that scroll off-screen. The catch is that every mount triggers React's reconciliation and a layout pass on the JS thread.

If your card has an image, a Reanimated animation, and a few TouchableOpacity regions, mounting a single card eats a few milliseconds. Multiply that by twenty cards on screen and scrolling no longer keeps up with your finger.

FlashList, built by Shopify, solves this problem with cell recycling. Instead of unmounting items, it reuses existing cells and swaps in new data — the same pattern UIKit and RecyclerView use natively. v2 makes this even smarter, so you no longer need to pass estimatedItemSize; it measures the cells it renders at runtime and adjusts as it goes.

The biggest v2 change: you no longer write estimatedItemSize

Anyone who used FlashList v1 will remember estimatedItemSize as a required prop. It was a hint about the average item height for the first render, and getting it wrong caused empty space, scroll jumps, and other strange artifacts.

v2 no longer needs this prop, because it measures rendered cells itself (it is runtime measurement, not a compiler step). In my experience, the more you struggled with v1, the more v2 feels like a relief.

Check this before you migrate

v2 was rebuilt for the New Architecture (Fabric). A project still on the old architecture cannot use v2 and should stay on v1. Compare your Expo SDK and React Native versions against the official compatibility notes first. I once skipped that check, ran npx expo install, and ended up staring at a build error late at night.

Here is how the three options differ:

AspectFlatListFlashList v1FlashList v2
Off-screen cellsUnmounted and discardedRecycledRecycled
Height estimationOptional via getItemLayoutestimatedItemSize requiredNot needed (measured at runtime)
ArchitectureEitherEitherNew Architecture expected
Resetting cell stateNot needed (fresh mount)Handle it yourselfuseRecyclingState available

This reflects what I checked at the time of writing; please confirm the details in the official FlashList documentation.

Migration steps for a Rork-generated app

Rork templates lean on FlatList by default, so we start with installation:

# Run from your project root
npx expo install @shopify/flash-list
 
# If you also use Reanimated, keep it on the latest matching version
npx expo install react-native-reanimated

Next, replace the components that render FlatList. Here is a minimal before/after.

// Before — FlatList version
import { FlatList } from "react-native";
 
export function PostList({ posts }: { posts: Post[] }) {
  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <PostCard post={item} />}
      // We tuned the offscreen window and initial render count,
      // but the gains were limited
      windowSize={10}
      initialNumToRender={8}
      removeClippedSubviews
    />
  );
}
// After — FlashList v2 version
import { FlashList } from "@shopify/flash-list";
 
export function PostList({ posts }: { posts: Post[] }) {
  return (
    <FlashList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <PostCard post={item} />}
      // estimatedItemSize is no longer needed in v2
      // Even with image-heavy cards, long lists stay smooth
    />
  );
}

The point is that you can delete most of FlatList's tuning props. windowSize, initialNumToRender, removeClippedSubviews, getItemLayout — these FlatList tunings do not need to come along. I made the mistake of leaving old props in place during migration and missed the runtime warnings, so always glance at the console once after the swap.

Lists with variable-height cards stay reliable

FlashList v2 shows the biggest improvement on lists with mixed item heights — the kind of social timeline where text-only cards sit alongside image cards.

type FeedItem =
  | { type: "text"; id: string; body: string }
  | { type: "image"; id: string; uri: string; caption: string };
 
export function Timeline({ items }: { items: FeedItem[] }) {
  return (
    <FlashList<FeedItem>
      data={items}
      keyExtractor={(item) => item.id}
      // getItemType massively improves cell recycling efficiency
      getItemType={(item) => item.type}
      renderItem={({ item }) => {
        if (item.type === "image") return <ImageCard item={item} />;
        return <TextCard item={item} />;
      }}
    />
  );
}

getItemType exists in v1 too but remains essential in v2. With it, image cards and text cards live in separate recycle pools, and you avoid forcing differently shaped cells to swap contents. I skipped it the first time and saw a brief flicker in a mixed list; if your item types are clearly distinct, it is cheap insurance.

Three places people get stuck

Sharing the traps I personally fell into so you can skip them.

First: nesting inside a ScrollView. This applies to all VirtualizedList descendants, and FlashList is no exception. Wrap a FlashList in a <ScrollView> and virtualization disables itself, mounting every item, ending up slower than FlatList. When you need a scrolling parent layout, lean on ListHeaderComponent and ListFooterComponent instead. The companion piece Resolving the VirtualizedList nesting warning covers the broader context.

Second: putting a key on the row component. Out of FlatList habit, I once wrote key={item.id} on the element returned from renderItem. That makes the cell get rebuilt on every recycle and cancels the benefit. Leave identity to keyExtractor, and define it outside the component (const keyExtractor = (item: Post) => item.id) so it is not recreated each render.

Third: mutating the data array. FlashList watches the data reference to compute diffs. When sorting or filtering, always return a new array — setItems([...items, newItem]) — never items.push(newItem). FlatList behaves the same way, but FlashList's deeper internal optimization makes mutation bugs harder to spot visually. If you store the list in Zustand, Redux, or a similar store, double-check that your selectors return stable references between renders; otherwise the recycle pool will thrash and you'll lose the gains.

Working with Reanimated and gesture-driven cards

A common Rork-app pattern is a card that supports swipe actions, scale animations, or shared element transitions powered by Reanimated. With FlatList you often had to fight the list's re-render behavior to keep gesture state stable. With FlashList v2 you mostly get this for free, but two details matter.

import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { FlashList } from "@shopify/flash-list";
 
function SwipeableCard({ post }: { post: Post }) {
  const offset = useSharedValue(0);
  const cardStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: withSpring(offset.value) }],
  }));
  // Reset shared values when this cell is recycled for a different item
  // by keying the shared value initialization on post.id
  // (useRecyclingState is another option instead of plain useState)
  return <Animated.View style={cardStyle}>{/* card body */}</Animated.View>;
}

The first detail is to initialize shared values per item, not per cell. When FlashList recycles a cell to render a different post, the component instance is preserved but the props change. If you keep gesture state in a ref, the recycled cell will inherit the previous card's swipe offset. The cleanest fix is to drive shared values from props derived from post.id, or reset them inside an effect that depends on post.id.

The second detail is to wrap heavy cards in React.memo. FlashList itself does not memoize your row component, so a parent re-render — for example, when a new item arrives at the top — can trigger renders for all visible cards. memo plus a stable keyExtractor cuts those wasted renders effectively.

Measure the improvement, don't trust the feeling

You don't have to rely on subjective impressions. The React Native Performance Monitor surfaces JS and UI thread frame rates in the top-right corner once you shake the device on a dev build and enable Show Perf Monitor.

// Rough timing helper for development builds (a guide, not a true FPS meter)
import { InteractionManager } from "react-native";
 
export function trackScrollPerformance(label: string) {
  const start = Date.now();
  return InteractionManager.runAfterInteractions(() => {
    const elapsed = Date.now() - start;
    if (__DEV__) {
      console.log(`[perf] ${label}: ${elapsed}ms`);
    }
    return elapsed;
  });
}

On my own device, a 500-item image card list ran at about 35–40 fps with FlatList and settled around 58–60 fps after moving to FlashList v2. Numbers swing with the device and the weight of your cards, so treat this as one data point and measure before and after with the same gesture in your own app.

Migrate one component at a time, starting with the worst offender

The closing thought I want to leave you with is that you don't have to swap your whole app at once. Pick one heavy list, replace it, and let the numbers confirm it. I keep to that order in my own app, because I've been burned by changing everything at once and not knowing which change helped. Pick the single laggiest list, replace it, measure the win, then move to the next. That order makes every migration tangible.

If you're going to try one thing today, choose the longest-list screen in your app and apply just the minimal example above. The replacement takes under thirty minutes and you'll feel the difference the moment you reopen the app. If your cards rely on animation, pair it with a quick review of combining Reanimated 3 with Gesture Handler to push the experience further.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-07-24
Collapsing Duplicate Requests Into One: A Reference-Counted Single-Flight Layer
When several components fire the same API call at launch, you get a burst of identical requests. Here is a single-flight layer that shares one in-flight promise instead: how to build the key that decides the folding, the trap of handing out a failed promise forever, the trap of one caller's abort cancelling everyone, and the test that keeps it all from regressing, with the real network numbers alongside.
⬡ Dev Tools2026-04-25
Skeleton Screens in Rork Apps — Practical Patterns for Better Perceived Performance
If your Rork app still shows a spinner on a blank screen during load, you are leaving a lot of perceived performance on the table. Here is how I drop skeleton screens and shimmer effects into Rork-generated React Native code without over-engineering it.
⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.