π Common Patterns
This guide shows you how to build common mobile app features using Shipnativeβs components and services. Each pattern is copy-paste ready and follows best practices.For AI Users: You can ask your AI to βbuild a form with validationβ or βcreate a feed with pull-to-refreshβ and it will use these patterns automatically from the
vibe/ context files.π Forms with Validation
Basic Login Form
Copy
import { View } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextField } from '@/components/TextField'
import { Button } from '@/components/Button'
import { useAuth } from '@/hooks/useAuth'
// 1. Define validation schema
const loginSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
})
type LoginFormData = z.infer<typeof loginSchema>
export function LoginForm() {
const { theme } = useUnistyles()
const { signIn } = useAuth()
// 2. Initialize form with validation
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
// 3. Handle submission
const onSubmit = async (data: LoginFormData) => {
const { error } = await signIn(data)
if (error) {
alert(error.message)
}
}
return (
<View style={styles.container}>
{/* Email Field */}
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextField
label="Email"
placeholder="you@example.com"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.email?.message}
keyboardType="email-address"
autoCapitalize="none"
/>
)}
/>
{/* Password Field */}
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<TextField
label="Password"
placeholder="Enter password"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.password?.message}
secureTextEntry
/>
)}
/>
{/* Submit Button */}
<Button
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
variant="filled"
>
{isSubmitting ? 'Logging in...' : 'Log In'}
</Button>
</View>
)
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing.md,
},
}))
- β Zod schema for type-safe validation
- β React Hook Form for performance
- β Error messages display automatically
- β Loading state during submission
π Data Lists & Feeds
Fetch and Display a List
Copy
import { FlatList, View, RefreshControl } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/services/supabase'
import { Text } from '@/components/Text'
import { Card } from '@/components/Card'
import { Spinner } from '@/components/Spinner'
interface Post {
id: string
title: string
content: string
created_at: string
}
export function PostsFeed() {
const { theme } = useUnistyles()
// Fetch posts with React Query
const {
data: posts,
isLoading,
error,
refetch,
isRefetching
} = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data as Post[]
},
})
// Loading state
if (isLoading) {
return (
<View style={styles.centerContainer}>
<Spinner />
</View>
)
}
// Error state
if (error) {
return (
<View style={styles.centerContainer}>
<Text preset="subheading">Failed to load posts</Text>
<Text preset="body" style={{ color: theme.colors.error }}>
{error.message}
</Text>
</View>
)
}
// Empty state
if (!posts || posts.length === 0) {
return (
<View style={styles.centerContainer}>
<Text preset="subheading">No posts yet</Text>
<Text preset="body">Be the first to create one!</Text>
</View>
)
}
// Render list
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Card preset="default" style={styles.card}>
<Text preset="subheading">{item.title}</Text>
<Text preset="body" numberOfLines={2}>
{item.content}
</Text>
<Text preset="caption" style={{ color: theme.colors.muted }}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</Card>
)}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={theme.colors.primary}
/>
}
contentContainerStyle={styles.listContent}
/>
)
}
const styles = StyleSheet.create((theme) => ({
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: theme.spacing.lg,
gap: theme.spacing.sm,
},
listContent: {
padding: theme.spacing.lg,
gap: theme.spacing.md,
},
card: {
gap: theme.spacing.sm,
},
}))
- β React Query for automatic caching and refetching
- β
Pull-to-refresh with
RefreshControl - β Loading, error, and empty states
- β Optimistic UI updates
π Pull-to-Refresh
Add to ScrollView
Copy
import { ScrollView, RefreshControl } from 'react-native'
import { useUnistyles } from 'react-native-unistyles'
import { useState } from 'react'
export function MyScreen() {
const { theme } = useUnistyles()
const [refreshing, setRefreshing] = useState(false)
const onRefresh = async () => {
setRefreshing(true)
try {
// Fetch fresh data
await fetchData()
} finally {
setRefreshing(false)
}
}
return (
<ScrollView
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]} // Android
/>
}
>
{/* Your content */}
</ScrollView>
)
}
π Infinite Scroll / Pagination
Copy
import { FlatList } from 'react-native'
import { useInfiniteQuery } from '@tanstack/react-query'
import { supabase } from '@/services/supabase'
const PAGE_SIZE = 20
export function InfinitePostsFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam = 0 }) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.range(pageParam, pageParam + PAGE_SIZE - 1)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < PAGE_SIZE) return undefined
return allPages.length * PAGE_SIZE
},
initialPageParam: 0,
})
const posts = data?.pages.flat() ?? []
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? <Spinner /> : null
}
/>
)
}
- β Automatic pagination with React Query
- β Load more on scroll
- β Loading indicator at bottom
πΎ Create, Update, Delete (CRUD)
Create a Post
Copy
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/services/supabase'
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (post: { title: string; content: string }) => {
const { data, error } = await supabase
.from('posts')
.insert(post)
.select()
.single()
if (error) throw error
return data
},
onSuccess: () => {
// Refresh posts list
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
// Usage in component
function CreatePostScreen() {
const createPost = useCreatePost()
const handleSubmit = async (data: FormData) => {
await createPost.mutateAsync({
title: data.title,
content: data.content,
})
// Navigate back or show success
}
return (
<Form onSubmit={handleSubmit}>
{/* Form fields */}
</Form>
)
}
Update a Post
Copy
export function useUpdatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Post> }) => {
const { data, error } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
onSuccess: (data) => {
// Update the specific post in cache
queryClient.setQueryData(['posts', data.id], data)
// Refresh list
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
Delete a Post
Copy
export function useDeletePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (postId: string) => {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
if (error) throw error
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
// Usage with confirmation
function PostCard({ post }) {
const deletePost = useDeletePost()
const handleDelete = () => {
Alert.alert(
'Delete Post',
'Are you sure you want to delete this post?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => deletePost.mutate(post.id),
},
]
)
}
return (
<Card>
{/* Post content */}
<Button onPress={handleDelete} variant="danger">
Delete
</Button>
</Card>
)
}
πΌοΈ Image Upload
Copy
import * as ImagePicker from 'expo-image-picker'
import { supabase } from '@/services/supabase'
import { decode } from 'base64-arraybuffer'
import { useState } from 'react'
export function useImageUpload() {
const [uploading, setUploading] = useState(false)
const uploadImage = async (bucket: string = 'avatars') => {
try {
// Request permissions
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
alert('Sorry, we need camera roll permissions!')
return null
}
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
})
if (result.canceled) return null
setUploading(true)
// Get the file
const image = result.assets[0]
const fileExt = image.uri.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const filePath = `${fileName}`
// Convert to base64
const response = await fetch(image.uri)
const blob = await response.blob()
const arrayBuffer = await new Response(blob).arrayBuffer()
// Upload to Supabase Storage
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, decode(arrayBuffer), {
contentType: image.type || 'image/jpeg',
})
if (error) throw error
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(filePath)
return publicUrl
} catch (error) {
console.error('Upload error:', error)
alert('Failed to upload image')
return null
} finally {
setUploading(false)
}
}
return { uploadImage, uploading }
}
// Usage
function AvatarPicker() {
const { uploadImage, uploading } = useImageUpload()
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const handlePickImage = async () => {
const url = await uploadImage('avatars')
if (url) {
setAvatarUrl(url)
// Update user profile with new avatar URL
}
}
return (
<Button onPress={handlePickImage} disabled={uploading}>
{uploading ? 'Uploading...' : 'Choose Avatar'}
</Button>
)
}
π Error Handling
Global Error Boundary
Copy
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { View, Text } from 'react-native'
import { Button } from '@/components/Button'
import { trackError } from '@/utils/analytics'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to Sentry
trackError(error, {
tags: { type: 'error-boundary' },
extra: { componentStack: errorInfo.componentStack },
})
}
render() {
if (this.state.hasError) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }}>
<Text style={{ fontSize: 20, marginBottom: 10 }}>Oops! Something went wrong</Text>
<Text style={{ marginBottom: 20, textAlign: 'center' }}>
{this.state.error?.message || 'Unknown error'}
</Text>
<Button
onPress={() => this.setState({ hasError: false, error: null })}
>
Try Again
</Button>
</View>
)
}
return this.props.children
}
}
// Wrap your app
function App() {
return (
<ErrorBoundary>
<YourApp />
</ErrorBoundary>
)
}
Async Error Handling
Copy
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return { data, error: null }
} catch (error) {
console.error('Fetch error:', error)
trackError(error as Error, {
tags: { type: 'api-error' },
})
return { data: null, error: error as Error }
}
}
π¨ Modal Dialogs
Copy
import { Modal, View, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Text } from '@/components/Text'
import { Button } from '@/components/Button'
import { useState } from 'react'
export function ConfirmDialog({
visible,
title,
message,
onConfirm,
onCancel,
}: {
visible: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}) {
const { theme } = useUnistyles()
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onCancel}
>
{/* Backdrop */}
<Pressable
style={styles.backdrop}
onPress={onCancel}
>
{/* Dialog */}
<Pressable style={styles.dialog}>
<Text preset="heading">{title}</Text>
<Text preset="body">{message}</Text>
<View style={styles.actions}>
<Button variant="outlined" onPress={onCancel}>
Cancel
</Button>
<Button variant="filled" onPress={onConfirm}>
Confirm
</Button>
</View>
</Pressable>
</Pressable>
</Modal>
)
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: theme.spacing.lg,
},
dialog: {
backgroundColor: theme.colors.card,
borderRadius: theme.radius.lg,
padding: theme.spacing.lg,
maxWidth: 400,
width: '100%',
gap: theme.spacing.md,
},
actions: {
flexDirection: 'row',
gap: theme.spacing.sm,
justifyContent: 'flex-end',
},
}))
// Usage
function MyScreen() {
const [showDialog, setShowDialog] = useState(false)
const handleDelete = () => {
// Perform delete
setShowDialog(false)
}
return (
<>
<Button onPress={() => setShowDialog(true)}>
Delete Item
</Button>
<ConfirmDialog
visible={showDialog}
title="Delete Item"
message="Are you sure you want to delete this item? This action cannot be undone."
onConfirm={handleDelete}
onCancel={() => setShowDialog(false)}
/>
</>
)
}
π± Bottom Sheet
Copy
import { useRef } from 'react'
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'
import { View } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Text } from '@/components/Text'
import { Button } from '@/components/Button'
export function MyScreen() {
const { theme } = useUnistyles()
const bottomSheetRef = useRef<BottomSheet>(null)
const openSheet = () => bottomSheetRef.current?.expand()
const closeSheet = () => bottomSheetRef.current?.close()
return (
<View style={{ flex: 1 }}>
<Button onPress={openSheet}>Open Bottom Sheet</Button>
<BottomSheet
ref={bottomSheetRef}
snapPoints={['25%', '50%', '90%']}
index={-1} // Start closed
enablePanDownToClose
backgroundStyle={{ backgroundColor: theme.colors.card }}
handleIndicatorStyle={{ backgroundColor: theme.colors.border }}
>
<BottomSheetView style={styles.sheetContent}>
<Text preset="heading">Bottom Sheet</Text>
<Text preset="body">Content goes here</Text>
<Button onPress={closeSheet}>Close</Button>
</BottomSheetView>
</BottomSheet>
</View>
)
}
const styles = StyleSheet.create((theme) => ({
sheetContent: {
padding: theme.spacing.lg,
gap: theme.spacing.md,
},
}))
π Next Steps
Components
Explore all pre-built components
State Management
Learn Zustand and React Query patterns
Forms
Advanced form patterns and validation
API Integration
Connect to backend services
Remember: These patterns are starting points. Customize them for your specific needs. The components and services are designed to be flexible and composable!