Skip to main content

πŸ“š 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

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,
  },
}))
Key Points:
  • βœ… 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

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,
  },
}))
Key Points:
  • βœ… 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

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

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
      }
    />
  )
}
Key Points:
  • βœ… Automatic pagination with React Query
  • βœ… Load more on scroll
  • βœ… Loading indicator at bottom

πŸ’Ύ Create, Update, Delete (CRUD)

Create a Post

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

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

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

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

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

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

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

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

Remember: These patterns are starting points. Customize them for your specific needs. The components and services are designed to be flexible and composable!