mobile-frontend
React Nativeのデザインパターン、NativeWindによるスタイリング、React Native Reusablesコンポーネント、モバイル特有のパターンに関する知識とスキルです。これらの技術を活用することで、iOSとAndroid両方で動作する高品質なモバイルアプリケーションを効率的に構築できます。NativeWindを使用したユーティリティファーストのスタイリングアプローチにより、開発速度を加速させながら、一貫性のあるUIデザインを実現します。また、再利用可能なコンポーネント設計により、保守性に優れたコードベースを実現できます。
description の原文を見る
React Native patterns, NativeWind styling, React Native Reusables components, mobile-specific patterns
SKILL.md 本文
モバイルフロントエンドスキル
コア原則
- すべてのテキストにスタイルを設定する - React Nativeはスタイルをカスケードしません。すべてのTextコンポーネントに直接classNameが必要です
- イベント駆動型フック - すべてのイベントではなく、特定のイベントのみをサブスクライブします
- 共有サービスインスタンス - コンポーネント全体で単一のサービスインスタンスのためにActivityRecorderProviderを使用します
- セマンティックカラー - 常にデザイントークン(text-foreground、bg-background等)を使用します
- ワンタイムコンシューム型ナビゲーション - URLパラメータの代わりにactivitySelectionStoreを複雑なオブジェクトナビゲーションに使用します
- プラットフォーム固有コード - NativeWindクラス(ios:pt-12 android:pt-6)を使用してプラットフォーム差異に対応します
従うべきパターン
パターン1: テキストスタイリング(重要)
使用時機: アプリ内のすべてのTextコンポーネント 理由: React Nativeはスタイル継承がありません
// ❌ 悪い例 - Textは何も継承しません
<View className="text-foreground">
<Text>This text has no color!</Text>
</View>
// ✅ 良い例 - すべてのTextに直接スタイルを設定
<View className="bg-background">
<Text className="text-foreground font-semibold">Title</Text>
<Text className="text-muted-foreground text-sm">Subtitle</Text>
</View>
// ✅ 良い例 - セマンティックカラーバリアント
<Text variant="h1" className="text-foreground">Heading</Text>
<Text variant="p" className="text-foreground">Paragraph</Text>
<Text variant="muted" className="text-muted-foreground">Secondary</Text>
重要なポイント:
- すべてのText要素を明示的にスタイル設定します
- セマンティックカラークラスを使用:
text-foreground、text-muted-foreground、text-destructive - Textコンポーネントで利用可能な場合はvariantプロップを使用します
- TextClassContextはカスタムコンポーネント内でデフォルトスタイリングを提供できます
パターン2: React Native Reusables アイコン使用法
使用時機: アイコンが必要な場合 理由: 一貫したスタイリングとテーマ設定を提供します
import { Icon } from '@/components/ui/icon';
import { Home, Activity, Settings } from 'lucide-react-native';
// ✅ 正しい - Iconラッパーコンポーネント
<Icon as={Home} size={24} className="text-foreground" />
// ✅ 正しい - ボタン内
<Button variant="default">
<Icon as={Activity} size={18} />
<Text className="text-primary-foreground">Start</Text>
</Button>
// ❌ 間違い - 直接的なアイコン使用
<Home size={24} /> {/* No styling context */}
重要なポイント:
- 常に
<Icon as={IconComponent} />パターンを使用します - アイコンは自動的にテーマカラーを取得します
- classNameで上書きできます
- Button/Cardコンポーネント内で動作します
パターン3: プロバイダー経由の共有サービス
使用時機: 複数のスクリーン全体でActivityRecorderサービスを使用 理由: 単一のサービスインスタンスを確保し、状態の分散化を防ぎます
// ✅ 良い例 - 単一の共有インスタンス
// recording layout (_layout.tsx)内
<ActivityRecorderProvider profile={profile}>
<Stack />
</ActivityRecorderProvider>;
// 任意のスクリーン内
function RecordScreen() {
const service = useSharedActivityRecorder(); // すべてのスクリーンで同じインスタンス
const state = useRecordingState(service);
// ...
}
// ❌ 悪い例 - 複数のインスタンス
function Screen1() {
const service = useActivityRecorder(profile); // インスタンスA
}
function Screen2() {
const service = useActivityRecorder(profile); // インスタンスB - 異なります!
}
重要なポイント:
- recording screensをActivityRecorderProviderでラップします
useSharedActivityRecorder()を使用してサービスにアクセスします- サービス状態はナビゲーション全体で保持されます
- アンマウント時に自動的にクリーンアップされます
パターン4: イベント駆動型フックサブスクリプション
使用時機: ActivityRecorderイベントをサブスクライブする 理由: 不要な再レンダリングを防ぎ、パフォーマンスを最適化します
// ✅ 良い例 - 特定のイベントをサブスクライブ
export function useRecordingState(
service: ActivityRecorderService | null,
): RecordingState {
const [state, setState] = useState<RecordingState>(
service?.state ?? "pending",
);
useEffect(() => {
if (!service) return;
setState(service.state);
const subscription = service.addListener("stateChanged", (newState) => {
setState(newState);
});
return () => subscription.remove(); // 常にクリーンアップ
}, [service]);
return state;
}
// ❌ 悪い例 - すべてのイベントをサブスクライブ
useEffect(() => {
const sub = service.onAnyEvent(() => {
setNeedsUpdate(true); // すべてのイベントでコンポーネント全体を再レンダリング
});
return () => sub.remove();
}, [service]);
重要なポイント:
- 特定のフックを使用:
useRecordingState、useCurrentReadings、useSessionStats - 各フックは特定のイベントのみをサブスクライブします
- 常にクリーンアップ関数を返します
- コンポーネントはサブスクライブされた値が変更される場合のみ再レンダリングされます
パターン5: ワンタイムコンシューム型ナビゲーションストア
使用時機: 複雑なオブジェクト(アクティビティプラン、アクティビティ選択)でナビゲート 理由: URLパラメータは複雑なオブジェクトをエンコードできません
// ✅ 良い例 - selection storeを使用
// ソーススクリーン内
activitySelectionStore.setSelection({ category: "run", location: "outdoor" });
router.push("/(internal)/record");
// デスティネーションスクリーン内
const selection = activitySelectionStore.peekSelection();
if (selection) {
service.selectActivityFromPayload(selection);
activitySelectionStore.consumeSelection(); // 使用後にクリア
}
// ❌ 悪い例 - 複雑なオブジェクトのURLパラメータ
router.push({
pathname: "/record",
params: { plan: activityPlan }, // シリアライゼーション失敗
});
重要なポイント:
- ナビゲーション前に複雑なオブジェクトを保存します
- デスティネーションスクリーンでワンタイムコンシュームします
- 読み取り後にセレクションをクリアします
- ナビゲーション戻りを適切に処理します
パターン6: NativeWind プラットフォーム固有スタイリング
使用時機: iOSとAndroid間で異なるスタイリング 理由: プラットフォーム固有のデザインガイドライン
// ✅ 良い例 - プラットフォームバリアント
<View className="ios:pt-12 android:pt-6 bg-background">
<Text className="ios:text-lg android:text-base text-foreground">
Platform Text
</Text>
</View>;
// ✅ 良い例 - セーフエリア処理
import { useSafeAreaInsets } from "react-native-safe-area-context";
function Screen() {
const insets = useSafeAreaInsets();
return (
<View style={{ paddingTop: insets.top }} className="flex-1 bg-background">
{/* Content */}
</View>
);
}
重要なポイント:
ios:とandroid:プレフィックスを使用します- ノッチ処理のためにセーフエリアインセットと組み合わせます
- 複雑な条件付きロジックにはPlatform.select()を使用します
パターン7: リトライ付きフォームミューテーション
使用時機: フォームでデータを作成/更新 理由: 自動リトライ、エラー処理、フィールドエラーマッピング
import { useFormMutation } from "@/lib/hooks/useFormMutation";
const mutation = useFormMutation({
mutationFn: async (data) => trpc.activities.create.mutate(data),
form, // React Hook Formインスタンス(オプション)
invalidateQueries: [["activities"]],
successMessage: "Activity created!",
retryAttempts: 2,
onSuccess: () => router.back(),
onError: (error) => {
// フィールドエラーは自動的にフォームにマップされます
toast.error(error.message);
},
});
<Button
onPress={form.handleSubmit(mutation.mutate)}
disabled={mutation.isLoading}
>
<Text className="text-primary-foreground">
{mutation.isLoading ? "Creating..." : "Create"}
</Text>
</Button>;
重要なポイント:
- 指数バックオフでネットワークエラーリトライを自動化します
- フィールドエラーをReact Hook Formにマップします
- 成功時のキャッシュ無効化
- 読み込み/成功/エラー状態が組み込まれています
パターン8: メモ化されたリスト項目
使用時機: 多くのアイテムを含むFlatList 理由: 不要な再レンダリングを防ぎます
import { memo } from "react";
export const ActivityListItem = memo(
({ activity, onPress }: Props) => {
return (
<TouchableOpacity onPress={onPress}>
<Text className="text-foreground">{activity.name}</Text>
</TouchableOpacity>
);
},
(prev, next) => {
// カスタム比較 - 等しい場合はtrueを返します(再レンダリングしません)
return (
prev.activity.id === next.activity.id &&
prev.activity.name === next.activity.name &&
prev.activity.distance_meters === next.activity.distance_meters
);
},
);
ActivityListItem.displayName = "ActivityListItem";
// FlatList内での使用
<FlatList
data={activities}
renderItem={({ item }) => (
<ActivityListItem activity={item} onPress={handlePress} />
)}
keyExtractor={(item) => item.id}
/>;
重要なポイント:
- カスタム比較でReact.memoを使用します
- レンダリングに影響するフィールドのみを比較します
- デバッグのためにdisplayNameを設定します
- FlatListで最高のパフォーマンスを発揮します
避けるべきアンチパターン
アンチパターン1: 複数のサービスインスタンス
問題: 各コンポーネントが異なるサービスを取得し、センサーが共有されません
// ❌ 悪い例
function Screen1() {
const service = useActivityRecorder(profile); // インスタンスA
}
function Screen2() {
const service = useActivityRecorder(profile); // インスタンスB - 異なります!
}
// ✅ 正しい例
<ActivityRecorderProvider profile={profile}>
<Screen1 />
<Screen2 />
</ActivityRecorderProvider>;
function Screen1() {
const service = useSharedActivityRecorder(); // 同じインスタンス
}
function Screen2() {
const service = useSharedActivityRecorder(); // 同じインスタンス
}
アンチパターン2: サブスクリプションクリーンアップの忘却
問題: イベントリスナーからのメモリリーク
// ❌ 悪い例
useEffect(() => {
service.addListener("stateChanged", handleStateChange);
// クリーンアップが不足!
}, [service]);
// ✅ 正しい例
useEffect(() => {
const subscription = service.addListener("stateChanged", handleStateChange);
return () => subscription.remove(); // 常にクリーンアップ
}, [service]);
アンチパターン3: イベントへの過度なサブスクリプション
問題: すべてのイベントでコンポーネントが再レンダリングされます
// ❌ 悪い例
useEffect(() => {
const sub = service.onAnyEvent(() => {
setNeedsUpdate(true); // すべてのイベントで再レンダリング
});
return () => sub.remove();
}, [service]);
// ✅ 正しい例
const readings = useCurrentReadings(service); // センサー更新時のみ再レンダリング
const stats = useSessionStats(service); // 統計変更時のみ再レンダリング
アンチパターン4: カスタムコンポーネント内でのTextClassContextの欠落
問題: カスタムボタン内のテキストにスタイリングがありません
// ❌ 悪い例
function CustomButton({ children }) {
return (
<Pressable>
<Text>{children}</Text> {/* スタイリングコンテキストなし */}
</Pressable>
);
}
// ✅ 正しい例
function CustomButton({ children, textClassName }) {
return (
<TextClassContext.Provider value={textClassName}>
<Pressable>
<Text>{children}</Text> {/* コンテキストスタイリングを取得 */}
</Pressable>
</TextClassContext.Provider>
);
}
ファイル構成
apps/mobile/
├── app/ # Expo Routerスクリーン
│ ├── (external)/ # パブリックルート
│ ├── (internal)/
│ │ ├── (tabs)/ # タブナビゲーション
│ │ ├── (standard)/ # スタックナビゲーション
│ │ └── record/ # レコーディングフロー
├── components/
│ ├── ui/ # React Native Reusables
│ ├── recording/ # レコーディングUI
│ ├── activity/ # アクティビティコンポーネント
│ └── shared/ # 共有コンポーネント
├── lib/
│ ├── hooks/ # カスタムフック
│ │ └── useActivityRecorder.ts # 8つの特化したフック
│ ├── stores/ # Zustandストア
│ ├── services/ # ビジネスロジック
│ └── providers/ # Reactコンテキストプロバイダー
└── assets/
ネーミング規則
- コンポーネント:
PascalCase→ActivityCard.tsx、RecordingFooter.tsx - フック:
camelCasewithuseプレフィックス →useActivityRecorder.ts、useFormMutation.ts - ストア:
camelCasewithStoreサフィックス →authStore.ts、activitySelectionStore.ts - ユーティリティ:
camelCase→formatDuration.ts - 定数:
SCREAMING_SNAKE_CASE→MAX_HEART_RATE = 220
一般的なシナリオ
シナリオ1: 新しいレコーディングスクリーンコンポーネントの作成
アプローチ:
- プロバイダーからサービスをインポート
- データニーズに特化したフックを使用
- すべてのText要素にスタイルを設定
- ローディング/エラー状態を処理
- ErrorBoundaryでラップ
例:
import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider";
import {
useRecordingState,
useCurrentReadings,
} from "@/lib/hooks/useActivityRecorder";
import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary";
function RecordingMetrics() {
const service = useSharedActivityRecorder();
const state = useRecordingState(service);
const readings = useCurrentReadings(service);
if (!service) {
return <Text className="text-muted-foreground">Loading...</Text>;
}
return (
<View className="p-4 bg-card">
<Text className="text-foreground text-lg font-semibold">
{readings.heartRate ? `${readings.heartRate} bpm` : "--"}
</Text>
</View>
);
}
export default function RecordingMetricsWithErrorBoundary() {
return (
<ErrorBoundary fallback={ScreenErrorFallback}>
<RecordingMetrics />
</ErrorBoundary>
);
}
シナリオ2: バリデーションと送信を含むフォーム
アプローチ:
- React Hook Form + Zodを使用
- 送信にuseFormMutationを使用
- フィールドエラーを自動的に処理
- ボタンにロード状態を表示
例:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { activitySchema } from "@repo/core/schemas";
import { useFormMutation } from "@/lib/hooks/useFormMutation";
import { trpc } from "@/lib/trpc";
function CreateActivityForm() {
const form = useForm({
resolver: zodResolver(activitySchema),
defaultValues: { name: "", type: "run", distance: 0 },
});
const mutation = useFormMutation({
mutationFn: async (data) => trpc.activities.create.mutate(data),
form,
invalidateQueries: [["activities"]],
successMessage: "Activity created!",
onSuccess: () => router.back(),
});
return (
<View className="p-4">
<Input
{...form.register("name")}
placeholder="Activity name"
className="bg-background"
/>
{form.formState.errors.name && (
<Text className="text-destructive text-sm">
{form.formState.errors.name.message}
</Text>
)}
<Button
onPress={form.handleSubmit(mutation.mutate)}
disabled={mutation.isLoading}
className="mt-4"
>
<Text className="text-primary-foreground">
{mutation.isLoading ? "Creating..." : "Create"}
</Text>
</Button>
</View>
);
}
依存関係
必須:
expov54+expo-routerv6+react-native-reusables(shadcn風のコンポーネント)nativewindv4lucide-react-native(アイコン)@tanstack/react-queryv5zustand(状態管理)
オプション:
react-hook-form+@hookform/resolvers(複雑なフォーム)expo-location(GPS追跡)expo-sensors(デバイスセンサー)
禁止事項:
@repo/supabaseから直接インポートしません(tRPCを使用)- モバイルアプリにデータベースクライアントをインポートしません
テスト要件
- React Native Testing Libraryでコンポーネント レンダリングをテストします
- testing libraryのrenderHookでフックをテストします
- 分離されたテストのためにサービス/ストアをモック化します
- jest.fn()でイベントハンドラーコールバックをテストします
- モック化されたルーターでナビゲーションをテストします
チェックリスト
モバイル実装のクイックリファレンス:
- すべてのTextコンポーネントにカラーを含むclassNameがあります
- アイコンは
<Icon as={Component} />パターンを使用します - サービスはuseSharedActivityRecorderからアクセスされます
- イベントサブスクリプションはuseEffect内でクリーンアップされます
- 複雑なナビゲーションはselection storeを使用します
- プラットフォーム固有スタイルはios:/android:プレフィックスを使用します
- フォームは送信にuseFormMutationを使用します
- リスト項目はカスタム比較でメモ化されます
- Error boundariesがスクリーンをラップします
- セーフエリアインセットがノッチ用に処理されます
関連スキル
- Core Package Skill - 純粋関数パターン
- Backend Skill - tRPC統合
- Testing Skill - モバイルテストパターン
バージョン履歴
- 1.0.0 (2026-01-21): コードベース分析に基づく初版
次回レビュー: 2026-02-21
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- majiayu000
- ライセンス
- MIT
- 最終更新
- 2026/5/4
Source: https://github.com/majiayu000/claude-skill-registry / ライセンス: MIT