Rork で作ったアプリに「引っ張って更新」を付けたら、更新後に画面が一瞬白くチラつきます。あるいはスクロール位置がいつの間にか一番上に戻っていることもあります。こういう違和感、気になり始めると一気に気になります。
RefreshControl 自体はとてもシンプルな API で、動かすだけなら10分もかからないはずです。それなのにチラつきやスクロール競合が起きるのは、だいたい 「更新中の状態管理」 と 「スクロールビューのネスト構造」 のどちらかに原因があります。今回は私が Rork 製アプリで実際に踏んだ落とし穴を、ビフォー・アフターのコード付きで整理していきます。
完成品としては「更新インジケータが滑らかに表示される」「更新後もスクロール位置が保持される」「ローディングが確実に止まる」の3点を目指します。
まず理解する — RefreshControl と ScrollView の関係
Pull to Refresh の仕組みは、Rork が裏で生成している React Native のコンポーネントに依存しています。構造としては ScrollView または FlatList の refreshControl プロパティに RefreshControl を渡すだけ、というシンプルなものです。
import { FlatList, RefreshControl } from "react-native";
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
/>
}
/>このコード自体に問題はありません。落とし穴が出てくるのは、refreshing という boolean と、実際のデータ更新処理との同期がズレたときです。refreshing が true のまま終わらないとインジケータが止まらず、逆に早すぎるタイミングで false に戻すと「ちゃんと更新された感」が出ません。この「同期のズレ」が、チラつきやスクロール戻りの原因の8割を占めます。
チラつく典型的な実装 — 何が起きているか
まずはよくあるダメなパターンを見ていきます。これは私が最初に書いた、Rork が生成したコードをそのまま少しだけ修正したバージョンです。
// ❌ チラつきが出る実装
function FeedScreen() {
const [items, setItems] = useState([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
fetchItems().then(setItems);
}, []);
const handleRefresh = async () => {
setRefreshing(true);
setItems([]); // ← ここが犯人
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} />
}
/>
);
}問題は setItems([]) です。リストを一度空にしてから新しいデータを入れ直しているので、古いデータが一瞬消える瞬間があり、そこで画面が白くチラつきます。さらに、空配列になることで FlatList のスクロール位置がリセットされ、ユーザーが読んでいた位置から強制的に一番上に戻ってしまいます。
公式ドキュメントには「古いデータは消してから新しく取得してください」とは書かれていません。ですが Rork の AI はパターンマッチで「リフレッシュならまずクリアする」という書き方を選びがちで、これが現場では一番よく見るアンチパターンになっています。
修正パターン1 — データを差し替えるだけにする
一番シンプルで効く修正です。古いデータを消さず、新しいデータで 置き換える だけにします。
// ✅ チラつかない実装
const handleRefresh = async () => {
setRefreshing(true);
try {
const fresh = await fetchItems();
setItems(fresh); // 古いデータは直接上書き
} catch (err) {
console.warn("refresh failed:", err);
} finally {
setRefreshing(false);
}
};変更点は setItems([]) を削除しただけですが、これだけでチラつきは消えます。React の再レンダリングは新旧の配列を比較して差分だけを描画するので、同じ id を持つ行は DOM(正確には Native View)が使い回され、スクロール位置も保持されます。
try / finally で囲んでいるのも重要で、fetchItems() が失敗したときに refreshing が true のまま残ってインジケータが回り続ける事故を防ぎます。これは地味ですが、本番では必ず起きる現象なので忘れないでおきたいところです。
修正パターン2 — keyExtractor を必ず安定させる
差し替えパターンが効くのは、「新旧のアイテムを React が同一と判定できる」ときだけです。判定の鍵を握るのが keyExtractor です。
// ❌ 不安定なキー(index は並び替えで壊れる)
keyExtractor={(_, index) => String(index)}
// ✅ 安定したキー(サーバー側のID)
keyExtractor={(item) => item.id}index をキーに使うと、並び順が変わっただけで「全行が別物」と判定されて、結局全部再描画されます。これもチラつきの原因になります。Rork に「リストを作って」と頼むと、たまに index ベースのキーが生成されることがあるので、動くようになった段階で一度プロンプトで明示的に修正を入れるのが確実です。
もし画像付きのリストなら、getItemLayout も設定しておくとスクロールの滑らかさが一段上がります。アイテムの高さが一定なら、次のように書けます。
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} />
}
/>「FlatList が空白で描画されない」「スクロールがガタつく」といった関連症状については、FlatList が空白で何も映らない—Rork アプリのリスト崩れを切り分ける にチェック順序をまとめていますので、あわせて読んでいただけると切り分けが早くなります。
修正パターン3 — ネストしたスクロールでの競合を避ける
もう一つハマりやすいのが、ScrollView の中に FlatList を入れているケースです。Rork に「ヘッダー画像の下にフィードを表示して」とお願いすると、外側を ScrollView、内側を FlatList にしたコードが生成されることがあります。
このとき Pull to Refresh を外側の ScrollView に付けると、内側の FlatList のスクロールと競合して「どちらも少しずつ動く」という奇妙な挙動になります。iOS ではギリギリ動いても、Android では反応しなくなることもあります。
解決策は「Pull to Refresh を付けるのは、実際にスクロールするコンポーネント一つだけ」という原則です。外側にヘッダーを置きたい場合は、FlatList の ListHeaderComponent を使うのが王道です。
// ✅ ネストを避ける
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Row item={item} />}
ListHeaderComponent={<HeroBanner />}
ListEmptyComponent={<EmptyState />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
/>ListHeaderComponent はスクロールと一緒に上に流れていきます。ヘッダーを固定したいなら、FlatList の外に別コンポーネントとして置き、FlatList 自身には flex: 1 を指定して残り領域を全部使わせるのがクリーンです。
修正パターン4 — インジケータの色と位置を整える
Android と iOS ではデフォルトのインジケータ表現が違います。iOS は引っ張った量に応じてくるくる回るスピナーで、Android は少し下がったところに丸いインジケータがポップアップします。アプリのテーマが暗めなのに、デフォルトの白いスピナーだと見えにくいことがあります。
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor="#ff6b35" // iOS のスピナー色
colors={["#ff6b35", "#004e89"]} // Android のインジケータ色(複数指定で順に切り替わる)
progressBackgroundColor="#ffffff" // Android の背景色
progressViewOffset={8} // インジケータを出す位置の調整(px)
/>私は progressViewOffset を 8〜16px くらい下げるのが好みです。ヘッダーが少し被っていても見やすくなります。デザイン重視のアプリなら、このあたりを合わせるだけで「こだわって作ってある感」が出て、印象が変わります。
エラー時に「更新失敗」を伝える
リフレッシュは必ず成功するわけではありません。電波が弱いところで引っ張ると fetch が失敗することもあります。try / catch で拾っておくだけでなく、ユーザーに軽く通知する仕組みを用意すると、UX としての完成度が上がります。
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("更新に失敗しました", "電波の良い場所で再度お試しください。");
} finally {
setRefreshing(false);
}
};ハプティック(振動フィードバック)を足すと、視覚に頼らない「更新できた/できなかった」の伝達ができます。触覚フィードバックの使い分けについては、Rork アプリのハプティック(触覚フィードバック)完全ガイド にまとめています。
最小構成のチェックリスト
ここまでの内容を、push する前に確認できるリストにまとめます。
setItems([])のような一旦クリアする処理を入れていない(古いデータは新しいデータで上書きするだけにする)keyExtractorにはサーバー側の安定したID(item.idなど)を指定しているrefreshingの切り替えをtry / finallyで確実にfalseに戻しているScrollViewとFlatListを入れ子にしていない(入れ子が必要ならListHeaderComponentを使う)tintColorまたはcolorsでアプリのテーマに合わせた色を指定している- リフレッシュ失敗時のユーザー通知を用意している(
Alertか Toast ライブラリ)
このうち最初の2つが守れていれば、チラつきとスクロール戻りはほぼ起きません。残りは「使っていて気持ちいい」レベルに上げるための仕上げになります。
次にやること
まずは今お持ちのアプリの一番よく使うリスト画面に、このパターンを適用してみてください。1画面に反映すれば感覚が掴めますし、他の画面に横展開するときも同じ考え方で整理できます。Rork にプロンプトを追加するときは「リフレッシュ時に既存データをクリアせず、新しいデータで置き換える実装にしてください。keyExtractor には item.id を使ってください」と明示すると、AI が生成するコードも安定します。
リスト周りのパフォーマンスをさらに詰めたい方には、Rork アプリのパフォーマンス最適化完全ガイド でメモ化・画像遅延読み込み・レンダリング最適化までまとめていますので、次の一手としておすすめです。