Suspenseを使う時は、子要素が0から再レンダリングされることに注意しよう
React Native(Expo)でFlowというチャット型メモアプリを開発しています。
この記事では、その開発中に直面したSuspenseの配置位置を間違えて発生した無限ループ問題について紹介します。
問題のあるコード
// _layout.tsx
import { DatabaseProvider } from "./provider"
export default function RootLayout() {
return (
<Suspense fallback={<Loading />}>
<DatabaseProvider>
{/* 子コンポーネント */}
</DatabaseProvider>
</Suspense>
);
}
// provider.tsx
export function DatabaseProvider({ children }: DatabaseProviderProps) {
return (
<SQLiteProvider
databaseName="test.db"
useSuspense
>
{children}
</SQLiteProvider>
);
}
この構造だと、アプリが無限ループに陥ります。
なぜこの書き方にしようとしたのか
expo-sqliteの公式ドキュメントには、以下のようにSQLiteProviderと同じコンポーネント内の1つ上のレイヤーとして<Suspense>が配置されています。
通常、これを真似すれば上記の無限ループ問題は発生しません。
import { SQLiteProvider, useSQLiteContext } from 'expo-sqlite';
import { Suspense } from 'react';
import { View, Text, StyleSheet } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Suspense fallback={<Fallback />}>
<SQLiteProvider databaseName="test.db" onInit={migrateDbIfNeeded} useSuspense>
<Header />
<Content />
</SQLiteProvider>
</Suspense>
</View>
);
}
しかし、<Suspeanse>への理解が浅くアプリの階層構造の一番上のレベルで<Suspense>を1度定義しておけば<Suspeanse>を何度も定義する必要がなくてDRYになるのでは?
と考えて、上記の構造を試しました。
なぜ無限ループになるのか
- SQLiteProviderが初期化中にPromiseをthrowする
- 外側のSuspenseがPromiseをキャッチしてLoading画面を表示する
- Promise解決後、DatabaseProvider含めて全体が再マウントされる
- DatabaseProviderが再マウントされることで、SQLiteProviderも再初期化される
- SQLiteProviderが再びPromiseをthrowする(1に戻る)
Suspenseがコンポーネントツリーの外側にあると、Promiseが解決されるたびにPromiseをthrowするコンポーネント自体が再マウントされてしまいます。これが無限ループの原因です。
実際、Reactの公式ドキュメントにも以下のようにSuspendされたコンポーネントの状態は保持されないと注意書きされています。
React does not preserve any state for renders that got suspended before they were able to mount for the first time. When the component has loaded, React will retry rendering the suspended tree from scratch.
解決策: Promiseをthrowするコンポーネントの近くにSuspenseを配置する
無限ループを防ぐには、先程示した公式ドキュメントの例と同じように、DatabaseProviderの中、SQLiteProviderのすぐ上で<Suspense>を配置すればOKです。
// _layout.tsx
export default function RootLayout() {
return (
<DatabaseProvider>
{/* 子コンポーネント */}
</DatabaseProvider>
);
}
// provider.tsx
export function DatabaseProvider({ children }: DatabaseProviderProps) {
return (
<Suspense fallback={<Loading />}>
<SQLiteProvider
databaseName="test.db"
options={{...}}
useSuspense
>
{children}
</SQLiteProvider>
</Suspense>
);
}
この配置にすることで、DatabaseProviderコンポーネント自体は再マウントされないため、SQLiteProviderも再初期化されず、無限ループが発生しません。
まとめ
この問題は、Promiseをthrowする任意のコンポーネントで発生しえます。
<Suspense>は配下のコンポーネントを再レンダリングするため、Promiseをthrowするコンポーネントのなるべく近くに配置すべきです。
今回のケースのように無限ループに陥ってしまったり、そうでなくても余分なデータの再取得やレンダリングにつながります。
参考リンク
Discussion