React Native / Expo パターン
Expo で本番用 React Native アプリを構築するための実践的なパターン集。ナビゲーション、状態管理、データ取得、リスト、スタイリング、ネイティブ API をカバーします。rules/react-native/ ルールセットと組み合わせて使用します。ルールは「何を」強制するかを示し、このスキルは「どのように」実装するかを示します。
以下に記載するライブラリ(NativeWind、Zustand/Jotai、TanStack Query)は、説明用に示した一般的で確立されたオプションであり、パターン自体が特定のパッケージよりも重要です。同等の機能を持つライブラリであれば代用可能です。Zod は ECC の既存の typescript/ ルールとの一貫性を保つために検証に使用しています。
これらのパターンは、Managed Expo ワークフロー(Expo Router、EAS、expo-* モジュール)と新しいアーキテクチャ(最近の Expo SDK のデフォルト、SDK 55 以降必須)を前提としています。ブラウザの DOM を前提としていません。React Native には <div> も URL バーも Web のデフォルトのデータ取得動作もありません。
アクティブ化のタイミング
以下の場合にこのスキルを使用します:
- React Native / Expo の画面、コンポーネント、ナビゲーションの構築または編集
- Expo Router(ファイルベースの
app/ディレクトリ)を使ったルーティングの設定 - 状態の配置場所の決定(サーバーキャッシュ vs クライアントストア vs ルートパラメータ vs フォーム)
- TanStack Query を使ったデータ取得と Zod を使ったレスポンス検証
- 長いリストや重いリストのレンダリング
- スタイリングアプローチ(NativeWind または StyleSheet)の選択と適用
- ネイティブデバイス API(カメラ、位置情報、通知)またはセキュアストレージへのアクセス
- モバイル固有の問題について RN コードのレビュー
Web/React-DOM のパターン(URL を状態として使う、<div>、SWR によるブラウザ向けデータ取得など)はここでは使用しないでください。これらは React Native には適用されません。
コアコンセプト
プロジェクト構造 (Expo Router)
app/ ディレクトリによるファイルベースルーティング。ルートファイルは薄く保ちます。パラメータの読み取りと検証を行い、components/ または features/ に配置された画面コンポーネントに委譲します。
app/
_layout.tsx # ルートスタック
(tabs)/
_layout.tsx # タブナビゲーター
index.tsx # ホーム
user/[id].tsx # 動的ルート
components/
features/
user/UserProfile.tsx
ナビゲーション:ルートパラメータの検証
ディープリンクや動的ルートは信頼できない文字列を渡します。使用前に Zod で検証します。
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}
状態管理:関心事を分離する
サーバーデータをクライアントストアに重複させないでください。各関心事にはそれぞれの居場所があります。
| 関心事 | 一般的な選択肢 |
|---|---|
| サーバー状態(リモートデータ) | サーバーキャッシュライブラリ (TanStack Query, SWR) |
| クライアント/UI 状態 | 軽量ストア (Zustand, Jotai) または Context |
| ルート/ナビゲーション状態 | Expo Router パラメータ |
| フォーム状態 | フォームライブラリ (例: React Hook Form) + スキーマ検証 |
| シークレット / トークン | expo-secure-store |
| 非機密データの永続化 | AsyncStorage / MMKV |
状態を本当に共有する必要があるまでは、ローカルの useState を優先してください。
データ取得:キャッシュライブラリ + Zod
useEffect 内の fetch ではなく、サーバーキャッシュライブラリ(TanStack Query、SWR)を使用します。境界で検証し、スキーマから型を推論します。ローディング、エラー、空の状態を明示的に処理します。(例では TanStack Query を使用)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}
リスト:仮想化、ScrollView で大きな配列を map しない
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // メモ化
initialNumToRender={10}
windowSize={5}
/>
大規模または異種混合リストには FlashList(Shopify)を使用します。
スタイリング:一つのシステムを選ぶ
StyleSheet.create() はフレームワークネイティブのオプションです。ユーティリティクラスライブラリ(例:NativeWind)も一般的な代替案です。一つを選択し、一貫性を保ちます。ホットパスでは JSX 内にインラインでスタイルオブジェクトを構築しないでください。
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">Hello</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>
ネイティブ API:フックでラップし、エフェクトをクリーンアップ
Expo SDK の呼び出しやサブスクリプションは JSX 内ではなく、use* フック内に保持します。常にクリーンアップを行います。
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// 状態を追跡し、座標だけでなく「ローディング中」と「許可拒否」を
// UI が区別してアクション可能なメッセージを表示できるようにする
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // アンマウント後は古い結果を無視
}, [])
return state
}
トークンのためのセキュアストレージ
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // キーチェーン / キーストア
const token = await SecureStore.getItemAsync('auth_token')
コード例
フルスクリーン:ルート → クエリ → リスト → 状態
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// メモ化によりレンダリング間で参照が安定(リストガイダンスを参照)
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>読み込み中…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">注文を読み込めませんでした。</Text></Centered>
if (!data?.length) return <Centered><Text>まだ注文がありません。</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}
フォーム:React Hook Form + Zod リゾルバー
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('無効なメールアドレス') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="メールアドレス"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="保存" onPress={handleSubmit(onSubmit)} />
</>
)
}
アンチパターン
// 誤り: ScrollView 内で大きな配列を map(仮想化なし、カクつく、メモリ使用量大)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// 正解: FlatList / FlashList
// 誤り: サーバーデータをクライアントストアにコピー(二重ソース、古いデータ)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// 正解: useQuery がサーバー状態を保持;必要なものを導出
// 誤り: AsyncStorage にトークンを保存(暗号化されていない)
await AsyncStorage.setItem('auth_token', token)
// 正解: expo-secure-store
// 誤り: ディープリンクのパラメータを信用する
const { id } = useLocalSearchParams(); fetchUser(id)
// 正解: 使用前に Zod で検証
// 誤り: ホットパスでレンダリングごとにインラインスタイルオブジェクトを再作成
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// 正解: モジュールスコープで StyleSheet.create、または NativeWind className
// 誤り: バンドルに実際のシークレットを同梱
const STRIPE_SECRET = 'sk_live_...'
// 正解: 特権呼び出しはサーバーサイドに保持;公開鍵のみをバックエンドのルールで保護して出荷
ベストプラクティス
- ルートファイルは薄く保ち、ロジックは画面コンポーネントと
use*フックに配置する - すべての外部入力(API レスポンス、ルートパラメータ、プッシュペイロード)を Zod で検証する
- TanStack Query にサーバー状態を管理させ、クライアントストアは小さく保つ
- ローディング、エラー、空の状態を常にレンダリングする — フォールバックなしのスピナーだけでは不十分
- リストを仮想化し、
renderItemをメモ化し、安定したkeyExtractorを提供する - アニメーションには
react-native-reanimated(UI スレッド)を使用し、JS スレッドで重い処理を行わない - トークンは
expo-secure-storeに保存し、クライアントを認可に信頼しない - セーフエリア、Dynamic Type、アクセシビリティのロール/ラベルを最初から尊重する
- リリース前にすべてのネイティブ依存関係の新しいアーキテクチャとの互換性を確認する
関連スキル
frontend-patterns— React/Next.js (Web) パターン;共有の React コンセプトに有用だが、DOM 固有coding-standards— RN コードに適用される TypeScript/JavaScript のイディオムtdd-workflow,e2e-testing— テストプロセス(Jest + React Native Testing Library、RN 向け Maestro/Detox を使用)security-review— 上記の RN バンドル/シークレットガイダンスを補完する一般的なセキュリティチェックリスト