旧ブログ(ISSEN)から移行しました

【React Native】FlashListとuseDeferredValueで固定ヘッダー付きリストを実装する

【React Native】FlashListとuseDeferredValueで固定ヘッダー付きリストを実装する

鳥井京祐
鳥井京祐11分で読めます
はてなブックマーク

はじめに

こんにちは、株式会社TOKOSの鳥井です!
今回は、React NativeのFlashListとReactのuseDeferredValueを組み合わせて、固定ヘッダー付きのリストを実装する方法についてまとめでみました!

最近開発していたモバイルアプリで「上部にカルーセル、その下にタブバー、さらにその下に一覧」という画面を実装しました。
タブバーを画面上部に固定する要件があり、それはFlashListのオプションで実現できました。しかし、タブを切り替えるたびに画面全体がチラつく、という問題が残りました🥲

本記事では、FlashListを用いて固定ヘッダーの実装と、useDeferredValueによるチラつきの解消方法を紹介します💪

対象読者

  • React Nativeを用いたモバイルアプリの開発をしている方
  • FlashList、useDeferredValueの使い方を学びたい方

完成イメージと画面構成

本記事で最終的に作る画面はこちらです。

これらをFlashListで実装していきます!
まずは、FlashListがどんなライブラリかを見ていきましょう💪

FlashListの概要

FlashListは、Shopifyが開発しているReact Native向けのリストコンポーネントライブラリです。
React Native組み込みのFlatListと互換性のあるAPIを持ちながら、より高速に動作するのが特長です!

FlashList - super fast list for react native

FlashList is a faster alternative to FlatList with a similar API. Migrate in a few seconds and get major performance boost.

shopify.github.io

基本的な使い方はFlatListと同じです。
表示したいデータの配列をdataに渡し、各要素の見た目をrenderItemで返すだけです。

FlashListの基本的な使い方
import { FlashList } from "@shopify/flash-list"
import { Text } from "react-native"
 
const products = [
  { id: 1, name: "商品A" },
  { id: 2, name: "商品B" },
  { id: 3, name: "商品C" },
]
 
function ProductList() {
  return (
    <FlashList
      data={products}
      renderItem={({ item }) => <Text>{item.name}</Text>}
      keyExtractor={(item) => item.id.toString()}
    />
  )
}

dataに渡した配列の各要素が、renderItemを通して順番に描画されます。
このあとの固定ヘッダー付きリストの実装で、この「1つの配列を渡して描画する」という仕組みを応用します!

また、本記事では2025年にリリースされたv2を前提とします。
v2では、内部実装が新アーキテクチャ(New Architecture)向けに書き直されました。

stickyHeaderIndicesでヘッダーを固定する

まずは、スクロールしてもタブバーだけを上部に固定する部分を実装します!
カルーセルはスクロールに合わせて流し、タブバーは上部に固定、という仕様を満たす必要があります。
これを実現するために使用するのが、FlashListのstickyHeaderIndicesです!
これは固定したい要素のインデックスを指定するためのpropです!

Usage | FlashList

If you are familiar with FlatList, you already know how to use FlashList. You can try out FlashList by changing the component name.

shopify.github.io
鳥井
鳥井

固定ヘッダーまわりの設定はstickyHeaderIndicesだけではありません!
stickyHeaderConfigを使うと、固定する位置のオフセットや背景の差し込みなどをさらに細かく調整できます。凝ったヘッダーにしたいときは、公式ドキュメントも覗いてみてください💪

リスト要素を1つの配列で定義

先程見たとおり、FlashListはdataに渡した1つの配列を描画します。
そしてstickyHeaderIndicesで固定できるのも、この配列に含めた要素だけです。
つまり、カルーセル・ヘッダー・商品を別々に置くのではなく、すべてを1つの配列にまとめる必要があります。
まず、配列の要素をユニオン型で定義します。

リスト要素の型定義
type ListItem = { type: "carousel" } | { type: "tabHeader" } | { type: "product"; product: Product }

この型をもとに、実際のデータを1次元の配列に並べます。
先頭にカルーセルとヘッダーを置き、その後ろに商品を展開します。

1つの配列にまとめる
const listData: ListItem[] = [
  { type: "carousel" },
  { type: "tabHeader" },
  ...products.map((product) => ({ type: "product" as const, product })),
]

描画はrenderItemで型ごとに振り分けます。

型ごとに描画を振り分ける
const renderItem = ({ item }: ListRenderItemInfo<ListItem>) => {
  switch (item.type) {
    case "carousel":
      return <Carousel />
    case "tabHeader":
      return <Tabs tabs={tabs} selectedTab={selectedTab} onChangeTab={setSelectedTab} />
    case "product":
      return <ProductCard product={item.product} />
    default:
      throw new Error(`Unexpected list item type: ${(item as ListItem).type}`)
  }
}

stickyHeaderIndicesで固定対象を指定する

あとはFlashListstickyHeaderIndicesを渡すだけです。
今回はtabHeaderrenderItemのindex[1]なので、[1]をpropとして渡します。

ヘッダーを固定する
<FlashList
  data={listData}
  stickyHeaderIndices={[1]}
  getItemType={(item) => item.type}
  keyExtractor={(item) => (item.type === "product" ? item.product.id.toString() : item.type)}
  renderItem={renderItem}
/>

stickyHeaderIndicesに渡したインデックスの要素は、スクロールでその位置に達すると上部に固定されます。
カルーセルはrenderItemのindex[0]なので固定されず、通常どおりスクロールで流れていきます。

その他にも指定している2つのpropsにも触れておきます。

prop説明
getItemType画面外のリストが描画されるタイミングで、種類ごとに各行の要素を再利用してくれる。異なる種類の要素がある場合は設定を推奨。
keyExtractor要素ごとに一意なキーを返す。商品はID、それ以外はtypeをキーにしている。v2では設定を推奨。
鳥井
鳥井

画面全体を1つのリストにまとめてしまうのが今回のポイントです!
今回のような異なるtypeの要素が混ざるリスト以外にも、使いどころがありそうですね😎

タブ切り替えのチラつきを防ぐ

固定ヘッダー付きのリストはできましたが、実はこの画面には1つ問題があります。
タブを切り替えると、選択したタブ内の商品をサーバーから取得する設計となっています。
このままだと、タブを押すたびに画面全体がチラついてしまいます🤮

これは、タブを切り替えるたびにデータの取得が走り、その読み込み中はカルーセルやタブも含めたリスト全体がローディング表示に置き換わってしまうためです。
次に、このチラつきの正体と解消する方法を見ていきます!

useSuspenseQueryで起きるチラつき

商品データの取得には、TanStack QueryのuseSuspenseQueryを使っています。
このフックを使用するコンポーネントは、表示に必要なデータがまだ取得できていない場合、描画をサスペンドします。
サスペンド中はそのコンポーネントの描画が保留され、代わりにもっとも近い親のSuspenseに指定したfallbackが表示されます。

Suspenseによるローディング表示の例
{/* 商品データの準備が整うまでは<LoadingIndicator />が表示される */}
<Suspense fallback={<LoadingIndicator />}>
  {/* ProductList内でuseSuspenseQueryを使用して商品データを取得 */}
  <ProductList />
</Suspense>

Suspense | TanStack Query React Docs

React Query can also be used with React

tanstack.com

本記事では、このuseSuspenseQueryをラップしたカスタムフックでデータの取得をしています。

カスタムフックの実装例
const useProductsQuery = (tabId: string) => {
  const { data } = useSuspenseQuery({
    queryKey: ["products", tabId],
    queryFn: () => fetchProducts(tabId),
  })
  return { products: data }
}

この問題はタブを切り替えたときに起こります。
選択中のタブをそのままクエリに渡すと、切り替えるたびにサスペンドします。
すると、リスト全体がフォールバックに置き換わってしまいます。

切り替えるたびにフォールバックが出る
const [selectedTab, setSelectedTab] = useState(tabs[0].id)
// selectedTabが変わるたびにサスペンドし、Suspenseのフォールバックが表示されてしまう
const { products } = useProductsQuery(selectedTab)

これでは、タブを押すたびに画面がチラついてしまいます。

useDeferredValueでチラつきを解消する

ここで活躍するのがuseDeferredValueです!
これは値の更新を遅延させ、緊急性の低い再レンダリングを後回しにするReact Hookです。

useDeferredValue – React

The library for web and native user interfaces

ja.react.dev

ポイントは、Suspenseと組み合わせたときの挙動です。
useDeferredValueを使うと、タブ切り替え時のレンダリングは次の2段階で進みます。

1回目: 古い値(変更前のdeferredTab)で再レンダリング。サスペンドしないため、前のタブの画面がそのまま残ります。
2回目: 新しい値(新しいdeferredTab)による裏側のレンダリング。ここでサスペンドしてもReactはフォールバックを表示せず、1回目で描画した古いUIを表示し続けます。データ取得が完了した時点で、新しいタブの内容に差し替わります。

2回目でサスペンドしてもReactはフォールバックを表示しないのが、チラつきが解消されるポイントです!

実装は、クエリに渡す値をuseDeferredValueでラップするだけです。

useDeferredValueで前の表示を保持する
const [selectedTab, setSelectedTab] = useState(tabs[0].id)
const deferredTab = useDeferredValue(selectedTab) 
const { products } = useProductsQuery(selectedTab) 
const { products } = useProductsQuery(deferredTab) 

この2つの値を使い分けるのがコツです。

  • selectedTab: 即座に更新される。タブのハイライト表示に使う。
  • deferredTab: 遅延して更新される。商品データの取得中は前のタブの値を保持し続ける。商品データの取得が完了すると、新しいタブの値に更新される。

タブを押すと、ハイライトは即座に動きます。
一方で商品リストは、deferredTabをクエリに渡すことで、新しいデータが届くまで前のタブの内容を表示し続けます。
これにより、フォールバックによるチラつきを抑えつつ、操作にはすぐ反応するUIになります!

Suspenseそのものの基本的な使い方は、以下の記事も参考にしてください。

【Next.js】Suspenseの初歩的な使い方

Next.jsでのSuspenseの基本的な使い方を解説。サーバーコンポーネントでのawaitパターンやクライアントコンポーネントでのuse hookを用いた実装方法を、コード例とともに紹介します。

鳥井
鳥井

最初はタブを押すと毎回ローディング画面になってしまい、対応方法が分からず悩みました。
useDeferredValueを挟むだけで解決したときは感動しました✨

全体の実装

ここまでの内容をまとめると、コンポーネントは次のようになります。
なお、CarouselTabsProductCardなどの子コンポーネントの実装は本記事では省略しています。

ProductList.tsx
import { FlashList, type ListRenderItemInfo } from "@shopify/flash-list"
import { useDeferredValue, useState } from "react"
import { Carousel } from "@/components/Carousel"
import { ProductCard } from "@/components/ProductCard"
import { Tabs } from "@/components/Tabs"
import { useProductsQuery } from "@/hooks/useProductsQuery"
import type { Product, Tab } from "@/types/product"
 
type ListItem = { type: "carousel" } | { type: "tabHeader" } | { type: "product"; product: Product }
 
type Props = {
  tabs: Tab[]
}
 
export function ProductList({ tabs }: Props) {
  const [selectedTab, setSelectedTab] = useState(tabs[0].id)
  const deferredTab = useDeferredValue(selectedTab)
  const { products } = useProductsQuery(deferredTab)
 
  const listData: ListItem[] = [
    { type: "carousel" },
    { type: "tabHeader" },
    ...products.map((product) => ({ type: "product" as const, product })),
  ]
 
  const renderItem = ({ item }: ListRenderItemInfo<ListItem>) => {
    switch (item.type) {
      case "carousel":
        return <Carousel />
      case "tabHeader":
        return <Tabs tabs={tabs} selectedTab={selectedTab} onChangeTab={setSelectedTab} />
      case "product":
        return <ProductCard product={item.product} />
      default:
        throw new Error(`Unexpected list item type: ${(item as ListItem).type}`)
    }
  }
 
  return (
    <FlashList
      data={listData}
      stickyHeaderIndices={[1]}
      getItemType={(item) => item.type}
      keyExtractor={(item) => (item.type === "product" ? item.product.id.toString() : item.type)}
      renderItem={renderItem}
    />
  )
}

なお、useProductsQueryはSuspenseに対応している前提です。
そのため、ProductListは呼び出し側で次のようにSuspenseで囲っておく必要があります。

呼び出し側の実装イメージ
<Suspense fallback={<FullScreenLoader />}>
  <ProductList tabs={tabs} />
</Suspense>

また、useSuspenseQueryは取得失敗時にエラーをスローするため、ErrorBoundaryも併用しておくと安全です!

おわりに

本記事では、React NativeのFlashListとReactのuseDeferredValueを使って、固定ヘッダー付きのリストを実装する方法を紹介しました!

固定ヘッダーはstickyHeaderIndices、タブ切り替え時のチラつきはuseDeferredValueで、それぞれ解決できます!
「固定タブ+一覧」のような画面を作る際は、ぜひ参考にしてください💪

鳥井
鳥井

最後まで読んでいただきありがとうございました!リスト周りのUIは奥が深いので、また気づきがあれば記事にしたいと思います😎

この記事を書いた人

鳥井京祐
鳥井京祐

TOKOSのバックエンドエンジニアです。最近はフロントエンド、特にReactに興味があります!趣味は麻雀です!