You add pull-to-refresh to your Rork app, drag down, let go—and the list flashes white for a split second. Or worse, the scroll position snaps to the top even though the user was halfway down the feed. Once you notice it, you can't un-notice it.
RefreshControl itself is a tiny API. Wiring it up takes less than ten minutes. The reason things still feel janky is almost always one of two problems: how the refreshing state is synchronized with the actual fetch, or how scroll views are nested. Below is a walkthrough of the exact traps I've hit in Rork-generated apps, with before-and-after code for each.
The goal is a refresh that shows a smooth indicator, keeps scroll position, and always stops loading when it's supposed to.
How RefreshControl and ScrollView fit together
Behind the scenes, Rork generates React Native components, so the plumbing is the same as any React Native app. You attach a RefreshControl to a ScrollView or FlatList via the refreshControl prop.
import { FlatList, RefreshControl } from "react-native";
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
/>
}
/>Nothing wrong with this on its own. What breaks is the coupling between the refreshing boolean and your data-fetching logic. If refreshing stays true, the spinner never stops. If you flip it to false too early, users don't feel like anything actually happened. That out-of-sync moment is where roughly 80% of flicker and scroll-jump bugs live.
The classic flickering implementation
Here's the pattern I wrote the first time I built this in Rork, which came straight out of the generated code with only minor tweaks. It looks reasonable. It is not.
// ❌ This flickers
function FeedScreen() {
const [items, setItems] = useState([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
fetchItems().then(setItems);
}, []);
const handleRefresh = async () => {
setRefreshing(true);
setItems([]); // ← the culprit
const fresh = await fetchItems();
setItems(fresh);
setRefreshing(false);
};
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
/>
);
}The offender is setItems([]). The list is emptied before the new data arrives, so the old rows disappear for a frame and you see a white flash. Because the data array is temporarily empty, FlatList resets its scroll position. Users who were reading halfway down the feed are yanked back to the top.
Official docs don't tell you to clear old data first. The reason Rork sometimes generates this shape is pattern matching—"refresh means reset, right?" It's the single most common anti-pattern I see in generated code.
Fix 1: Just replace the data
The simplest fix that works: drop the setItems([]) line. Let React reconcile the old and new arrays.
// ✅ No flicker
const handleRefresh = async () => {
setRefreshing(true);
try {
const fresh = await fetchItems();
setItems(fresh); // overwrite directly
} catch (err) {
console.warn("refresh failed:", err);
} finally {
setRefreshing(false);
}
};React diffs the two arrays by key. Rows with the same id keep their native views, which means scroll position is preserved and there's no repaint of unchanged content.
The try / finally wrapper matters more than it looks. Without it, a failed fetch leaves refreshing stuck on true, and the spinner never stops. This will absolutely happen in production, so make it non-optional.
Fix 2: Keep keyExtractor stable
The replace-don't-clear pattern only works when React can identify each row. The keyExtractor is what makes that possible.
// ❌ Unstable (index breaks on reorder)
keyExtractor={(_, index) => String(index)}
// ✅ Stable (server-side id)
keyExtractor={(item) => item.id}Using index means any reordering makes every row look like a new one, forcing a full redraw. That's another flicker source. Rork occasionally generates index-based keys, especially on early prompts—catch it once the list works and correct it with a follow-up prompt.
If rows have a fixed height, adding getItemLayout makes scrolling noticeably smoother because the list no longer measures each row as it appears.
const ROW_HEIGHT = 88;
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
/>For related symptoms—FlatLists rendering blank, scroll stuttering—I've written a separate troubleshooting flow: FlatList rendering blank in Rork apps—how to narrow down the cause. Worth having open when debugging.
Fix 3: Avoid nested scrolls
The other trap is nesting a FlatList inside a ScrollView. Ask Rork for "a feed below a hero image" and you might get exactly that layout. Put pull-to-refresh on the outer ScrollView and it fights with the inner FlatList—on iOS both move slightly, on Android it sometimes stops responding altogether.
The rule is: pull-to-refresh belongs on exactly one scrollable component. If you want a header above the list, use the FlatList's built-in ListHeaderComponent instead of wrapping everything in a ScrollView.
// ✅ No nesting
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
ListHeaderComponent={<HeroBanner />}
ListEmptyComponent={<EmptyState />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
/>ListHeaderComponent scrolls with the list. If you need a header that stays pinned, put it outside the FlatList and give the FlatList flex: 1 so it fills the remaining space cleanly.
Fix 4: Style the indicator to match your app
iOS and Android render different defaults. iOS has a spinner that scales as you pull; Android drops a circular indicator from the top. The default white spinner is invisible on a light-themed header, and hard to read on a colorful gradient background.
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#ff6b35" // iOS spinner color
colors={["#ff6b35", "#004e89"]} // Android colors (cycles through)
progressBackgroundColor="#ffffff" // Android background
progressViewOffset={8} // push indicator down (px)
/>I usually push progressViewOffset down by 8–16 pixels. It keeps the indicator visible even when there's a partially transparent header overlay. These small touches are what make an app feel designed rather than generated.
Surfacing errors gracefully
Refresh is not a guaranteed operation. Weak signal areas will fail the fetch. Catching it is step one; letting the user know is step two.
import * as Haptics from "expo-haptics";
import { Alert } from "react-native";
const handleRefresh = async () => {
setRefreshing(true);
try {
const fresh = await fetchItems();
setItems(fresh);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} catch (err) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
Alert.alert("Couldn't refresh", "Please try again when your connection is stronger.");
} finally {
setRefreshing(false);
}
};Haptic feedback communicates success or failure without relying on sight. For more on when each pattern feels right, I've collected the conventions in The Rork haptic feedback complete guide.
A pre-push checklist
Before you commit the change, run through this list.
- No
setItems([])-style reset. Replace old data with new; don't clear first. keyExtractoruses a stable server-side id (notindex).try / finallyguaranteessetRefreshing(false)no matter what happens.- No
ScrollViewwrapping aFlatList. UseListHeaderComponentinstead. tintColororcolorsmatch your app's theme, not the platform default.- Refresh failures surface as a toast, alert, or haptic—some sign the user can read.
The first two items kill the flicker and the scroll-jump. The rest are what take it from "works" to "feels good."
Where to go next
Pick your most-used list screen and apply this pattern first. One screen is enough to internalize the shape; after that, rolling it out to the rest of the app is mechanical. When you prompt Rork for new list screens, add a line like "on refresh, replace the data in-place instead of clearing—and use item.id for keyExtractor." That alone makes the generated code behave.
If you want to push list performance further, the Rork app performance optimization complete guide walks through memoization, image lazy-loading, and render avoidance. It's the natural next step after your refresh stops flickering.