Mobile Frontend Skill
Core Principles
-
Every Text Must Be Styled - React Native doesn't cascade styles; every Text component needs direct className
-
Event-Driven Hooks - Subscribe to specific events only, not all events
-
Shared Service Instances - Use ActivityRecorderProvider for single service instance across components
-
Semantic Colors - Always use design tokens (text-foreground, bg-background, etc.)
-
Consume-Once Navigation - Use activitySelectionStore for complex object navigation instead of URL params
-
Platform-Specific Code - Use NativeWind classes (ios:pt-12 android:pt-6) for platform differences
Patterns to Follow
Pattern 1: Text Styling (CRITICAL)
When to use: Every single Text component in the app Why: React Native has no style inheritance
// ❌ BAD - Text inherits nothing <View className="text-foreground"> <Text>This text has no color!</Text> </View>
// ✅ GOOD - Direct styling on every Text <View className="bg-background"> <Text className="text-foreground font-semibold">Title</Text> <Text className="text-muted-foreground text-sm">Subtitle</Text> </View>
// ✅ GOOD - Semantic color variants <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>
Key Points:
-
Style every Text element explicitly
-
Use semantic color classes: text-foreground , text-muted-foreground , text-destructive
-
Use variant prop when available on Text component
-
TextClassContext can provide default styling in custom components
Pattern 2: React Native Reusables Icon Usage
When to use: Any time you need an icon Why: Provides consistent styling and theming
import { Icon } from '@/components/ui/icon'; import { Home, Activity, Settings } from 'lucide-react-native';
// ✅ CORRECT - Icon wrapper component <Icon as={Home} size={24} className="text-foreground" />
// ✅ CORRECT - In Button <Button variant="default"> <Icon as={Activity} size={18} /> <Text className="text-primary-foreground">Start</Text> </Button>
// ❌ WRONG - Direct icon usage <Home size={24} /> {/* No styling context */
Key Points:
-
Always use <Icon as={IconComponent} /> pattern
-
Icons automatically get theme colors
-
Can override with className
-
Works inside Button/Card components
Pattern 3: Shared Service via Provider
When to use: ActivityRecorder service across multiple screens Why: Ensures single service instance, prevents state fragmentation
// ✅ GOOD - Single shared instance // In recording layout (_layout.tsx) <ActivityRecorderProvider profile={profile}> <Stack /> </ActivityRecorderProvider>;
// In any screen function RecordScreen() { const service = useSharedActivityRecorder(); // Same instance everywhere const state = useRecordingState(service); // ... }
// ❌ BAD - Multiple instances function Screen1() { const service = useActivityRecorder(profile); // Different instance } function Screen2() { const service = useActivityRecorder(profile); // Different instance }
Key Points:
-
Wrap recording screens with ActivityRecorderProvider
-
Use useSharedActivityRecorder() to access service
-
Service state persists across navigation
-
Automatic cleanup on unmount
Pattern 4: Event-Driven Hook Subscriptions
When to use: Subscribing to ActivityRecorder events Why: Prevents unnecessary re-renders, optimizes performance
// ✅ GOOD - Subscribe to specific event 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(); // Always clean up
}, [service]);
return state; }
// ❌ BAD - Subscribe to all events useEffect(() => { const sub = service.onAnyEvent(() => { setNeedsUpdate(true); // Re-render entire component on any event }); return () => sub.remove(); }, [service]);
Key Points:
-
Use specific hooks: useRecordingState , useCurrentReadings , useSessionStats
-
Each hook subscribes to specific events only
-
Always return cleanup function
-
Component only re-renders when subscribed values change
Pattern 5: Consume-Once Navigation Store
When to use: Navigating with complex objects (activity plans, activity selection) Why: URL params can't encode complex objects
// ✅ GOOD - Use selection store // In source screen activitySelectionStore.setSelection({ category: "run", location: "outdoor" }); router.push("/(internal)/record");
// In destination screen const selection = activitySelectionStore.peekSelection(); if (selection) { service.selectActivityFromPayload(selection); activitySelectionStore.consumeSelection(); // Clear after use }
// ❌ BAD - URL params for complex objects router.push({ pathname: "/record", params: { plan: activityPlan }, // Serialization fails });
Key Points:
-
Store complex objects before navigation
-
Consume once in destination screen
-
Clear selection after reading
-
Handles navigation back gracefully
Pattern 6: NativeWind Platform-Specific Styling
When to use: Different styling for iOS vs Android Why: Platform-specific design guidelines
// ✅ GOOD - Platform variants <View className="ios:pt-12 android:pt-6 bg-background"> <Text className="ios:text-lg android:text-base text-foreground"> Platform Text </Text> </View>;
// ✅ GOOD - Safe area handling 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> ); }
Key Points:
-
Use ios: and android: prefixes
-
Combine with safe area insets for notch handling
-
Platform.select() for complex conditional logic
Pattern 7: Form Mutation with Retry
When to use: Creating/updating data with forms Why: Automatic retry, error handling, field error mapping
import { useFormMutation } from "@/lib/hooks/useFormMutation";
const mutation = useFormMutation({ mutationFn: async (data) => trpc.activities.create.mutate(data), form, // React Hook Form instance (optional) invalidateQueries: [["activities"]], successMessage: "Activity created!", retryAttempts: 2, onSuccess: () => router.back(), onError: (error) => { // Field errors automatically mapped to form toast.error(error.message); }, });
<Button onPress={form.handleSubmit(mutation.mutate)} disabled={mutation.isLoading}
<Text className="text-primary-foreground"> {mutation.isLoading ? "Creating..." : "Create"} </Text> </Button>;
Key Points:
-
Automatic network error retry with exponential backoff
-
Field errors mapped to React Hook Form
-
Cache invalidation on success
-
Loading/success/error states built-in
Pattern 8: Memoized List Items
When to use: FlatList with many items Why: Prevents unnecessary re-renders
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) => { // Custom comparison - return true if equal (DON'T re-render) return ( prev.activity.id === next.activity.id && prev.activity.name === next.activity.name && prev.activity.distance_meters === next.activity.distance_meters ); }, );
ActivityListItem.displayName = "ActivityListItem";
// Usage in FlatList <FlatList data={activities} renderItem={({ item }) => ( <ActivityListItem activity={item} onPress={handlePress} /> )} keyExtractor={(item) => item.id} />;
Key Points:
-
Use React.memo with custom comparison
-
Compare only fields that affect rendering
-
Set displayName for debugging
-
Use with FlatList for best performance
Anti-Patterns to Avoid
Anti-Pattern 1: Multiple Service Instances
Problem: Each component gets different service, sensors not shared
// ❌ BAD function Screen1() { const service = useActivityRecorder(profile); // Instance A } function Screen2() { const service = useActivityRecorder(profile); // Instance B - different! }
// ✅ CORRECT <ActivityRecorderProvider profile={profile}> <Screen1 /> <Screen2 /> </ActivityRecorderProvider>;
function Screen1() { const service = useSharedActivityRecorder(); // Same instance } function Screen2() { const service = useSharedActivityRecorder(); // Same instance }
Anti-Pattern 2: Forgetting Subscription Cleanup
Problem: Memory leaks from event listeners
// ❌ BAD useEffect(() => { service.addListener("stateChanged", handleStateChange); // Missing cleanup! }, [service]);
// ✅ CORRECT useEffect(() => { const subscription = service.addListener("stateChanged", handleStateChange); return () => subscription.remove(); // Always clean up }, [service]);
Anti-Pattern 3: Over-Subscribing to Events
Problem: Component re-renders on every event
// ❌ BAD useEffect(() => { const sub = service.onAnyEvent(() => { setNeedsUpdate(true); // Re-render on ANY event }); return () => sub.remove(); }, [service]);
// ✅ CORRECT const readings = useCurrentReadings(service); // Only re-renders on sensor updates const stats = useSessionStats(service); // Only re-renders on stat changes
Anti-Pattern 4: Missing TextClassContext in Custom Components
Problem: Text inside custom button has no styling
// ❌ BAD function CustomButton({ children }) { return ( <Pressable> <Text>{children}</Text> {/* No styling context */} </Pressable> ); }
// ✅ CORRECT function CustomButton({ children, textClassName }) { return ( <TextClassContext.Provider value={textClassName}> <Pressable> <Text>{children}</Text> {/* Gets context styling */} </Pressable> </TextClassContext.Provider> ); }
File Organization
apps/mobile/ ├── app/ # Expo Router screens │ ├── (external)/ # Public routes │ ├── (internal)/ │ │ ├── (tabs)/ # Tab navigation │ │ ├── (standard)/ # Stack navigation │ │ └── record/ # Recording flow ├── components/ │ ├── ui/ # React Native Reusables │ ├── recording/ # Recording UI │ ├── activity/ # Activity components │ └── shared/ # Shared components ├── lib/ │ ├── hooks/ # Custom hooks │ │ └── useActivityRecorder.ts # 8 specialized hooks │ ├── stores/ # Zustand stores │ ├── services/ # Business logic │ └── providers/ # React Context providers └── assets/
Naming Conventions
-
Components: PascalCase → ActivityCard.tsx , RecordingFooter.tsx
-
Hooks: camelCase with use prefix → useActivityRecorder.ts , useFormMutation.ts
-
Stores: camelCase with Store suffix → authStore.ts , activitySelectionStore.ts
-
Utilities: camelCase → formatDuration.ts
-
Constants: SCREAMING_SNAKE_CASE → MAX_HEART_RATE = 220
Common Scenarios
Scenario 1: Creating a New Recording Screen Component
Approach:
-
Import service from provider
-
Use specific hooks for data needs
-
Style all Text elements
-
Handle loading/error states
-
Wrap with ErrorBoundary
Example:
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> ); }
Scenario 2: Form with Validation and Submission
Approach:
-
Use React Hook Form + Zod
-
Use useFormMutation for submission
-
Handle field errors automatically
-
Show loading state on button
Example:
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>
); }
Dependencies
Required:
-
expo v54+
-
expo-router v6+
-
react-native-reusables (shadcn-inspired components)
-
nativewind v4
-
lucide-react-native (icons)
-
@tanstack/react-query v5
-
zustand (state management)
Optional:
- react-hook-form
- @hookform/resolvers (complex forms)
-
expo-location (GPS tracking)
-
expo-sensors (device sensors)
Forbidden:
-
Never import from @repo/supabase directly (use tRPC)
-
Never import database clients in mobile app
Testing Requirements
-
Test component rendering with React Native Testing Library
-
Test hooks with renderHook from testing library
-
Mock services/stores for isolated testing
-
Test event handler callbacks with jest.fn()
-
Test navigation with mocked router
Checklist
Quick reference for mobile implementation:
-
Every Text component has className with color
-
Icons use <Icon as={Component} /> pattern
-
Service accessed via useSharedActivityRecorder
-
Event subscriptions cleaned up in useEffect
-
Complex navigation uses selection store
-
Platform-specific styles use ios:/android: prefixes
-
Forms use useFormMutation for submissions
-
List items memoized with custom comparison
-
Error boundaries wrap screens
-
Safe area insets handled for notches
Related Skills
-
Core Package Skill - Pure function patterns
-
Backend Skill - tRPC integration
-
Testing Skill - Mobile testing patterns
Version History
- 1.0.0 (2026-01-21): Initial version based on codebase analysis
Next Review: 2026-02-21