React Native / Expo パターン集
プロダクション向け React Native アプリを Expo で構築するための実践的なパターン。ナビゲーション、状態管理、データフェッチ、リスト、スタイリング、ネイティブ API をカバーします。rules/react-native/ ルールセットと組み合わせて使用します。ルールは 何を 強制するかを示し、このスキルは どのように 実装するかを示します。
以下に挙げるライブラリ(NativeWind、Zustand/Jotai、TanStack Query)は、よく使われる確立されたオプションを例として示したものです。パターン自体が重要であり、特定のパッケージではありません。同等の機能を持つ別のライブラリでも問題ありません。Zod は、ECC の既存の typescript/ ルールと一貫性を保つためにバリデーションに使用しています。
これらのパターンは、Managed Expo ワークフロー(Expo Router、EAS、expo-* モジュール)を前提とし、新しいアーキテクチャ(最近の Expo SDK ではデフォルト、SDK 55 以降必須)上で動作します。ブラウザの DOM(<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 の呼び出しやサブスクリプションは use* フック内に保持し、JSX には直接記述しません。必ずクリーンアップを行います。
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) // Keychain / Keystore
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>Loading…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">Could not load orders.</Text></Centered>
if (!data?.length) return <Centered><Text>No orders yet.</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('Invalid 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="Email address"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="Save" onPress={handleSubmit(onSubmit)} />
</>
)
}
アンチパターン
// 間違い:ScrollView 内で大きな配列を map している(仮想化なし、カクカク、メモリ大量消費)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// 正しい:FlatList / FlashList
// 間違い:サーバーデータをクライアントストアにコピー(2つの情報源、古いデータ)
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、アクセシビリティのロール/ラベルを最初から尊重する
- リリース前にすべてのネイティブ依存関係の New Architecture 互換性を確認する
関連スキル
frontend-patterns— React/Next.js(Web)パターン。共通の React 概念に役立つが、DOM 固有の部分を含むcoding-standards— RN コードに適用される TypeScript/JavaScript イディオムtdd-workflow、e2e-testing— テストプロセス(Jest + React Native Testing Library、Maestro/Detox for RN を使用)security-review— RN バンドル/シークレットガイダンスを補完する一般的なセキュリティチェックリスト