Skip to main content
Shipnative uses Zustand for local/global state and React Query for server data. This separation keeps your code clean and performant.

Zustand: Global Client-Side State

Zustand is a small, fast, and scalable bear-bones state-management solution. It’s ideal for managing UI state, user preferences, and any other client-side data that doesn’t require server interaction.

Key Features

  • Simple API: Easy to learn and use, with a minimal boilerplate.
  • Hooks-based: Integrates seamlessly with React’s functional components.
  • Performant: Renders components only when necessary, optimizing performance.
  • Scalable: Suitable for small to large applications.

Usage Pattern

  1. Define Your Store: Create a store using create from zustand.
    // apps/app/app/stores/counterStore.ts
    import { create } from 'zustand'
    
    interface CounterState {
      count: number
      increment: () => void
      decrement: () => void
    }
    
    export const useCounterStore = create<CounterState>((set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
    }))
    
  2. Use in Components: Access state and actions using the custom hook.
    // apps/app/app/screens/CounterScreen.tsx
    import { View, Text, Button } from 'react-native'
    import { useCounterStore } from '@/stores/counterStore'
    export const CounterScreen = () => {
      const { count, increment, decrement } = useCounterStore()
    
      return (
        <View>
          <Text>Count: {count}</Text>
          <Button title="Increment" onPress={increment} />
          <Button title="Decrement" onPress={decrement} />
        </View>
      )
    }
    

Best Practices with Zustand

  • Atomic Stores: Create small, focused stores for specific pieces of state.
  • Selectors: Use selectors to extract only the necessary parts of the state, preventing unnecessary re-renders.
  • Immutability: Always update state immutably (e.g., using spread syntax).

React Query: Server-Side State & Data Fetching

React Query (also known as TanStack Query) is a powerful library for managing, caching, synchronizing, and updating server state in your React Native applications. It handles the complexities of data fetching, leaving you with more time to focus on your UI.

Key Features

  • Automatic Caching: Caches fetched data, reducing redundant network requests.
  • Background Refetching: Automatically refetches stale data in the background.
  • Optimistic Updates: Provides a smooth user experience by updating the UI before the server responds.
  • Error Handling: Built-in mechanisms for handling and retrying failed requests.
  • Pagination & Infinite Scrolling: Simplifies complex data fetching patterns.

Usage Pattern

  1. Define Your Query Function: A simple async function that fetches data.
    // apps/app/app/services/api.ts
    import { supabase } from './supabase'
    
    export const fetchPosts = async () => {
      const { data, error } = await supabase.from('posts').select('*')
      if (error) throw error
      return data
    }
    
    export const addPost = async (newPost: { title: string; content: string }) => {
      const { data, error } = await supabase.from('posts').insert(newPost).select().single()
      if (error) throw error
      return data
    }
    
  2. Use in Components with useQuery or useMutation:
    // apps/app/app/screens/PostsScreen.tsx
    import { View, Text, FlatList, Button, ActivityIndicator } from 'react-native'
    import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
    import { fetchPosts, addPost } from '@/services/api'
    export const PostsScreen = () => {
      const queryClient = useQueryClient()
      const { data: posts, isLoading, isError, error } = useQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
      })
    
      const addPostMutation = useMutation({
        mutationFn: addPost,
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ['posts'] }) // Refetch posts after successful addition
        },
      })
    
      if (isLoading) return <ActivityIndicator />
      if (isError) return <Text>Error: {error?.message}</Text>
    
      return (
        <View>
          <FlatList
            data={posts}
            keyExtractor={(item) => item.id.toString()}
            renderItem={({ item }) => <Text>{item.title}</Text>}
          />
          <Button
            title="Add New Post"
            onPress={() => addPostMutation.mutate({ title: 'New Post', content: 'Hello from React Query!' })}
            disabled={addPostMutation.isPending}
          />
        </View>
      )
    }
    

Best Practices with React Query

  • Query Keys: Use descriptive and consistent query keys for effective caching and invalidation.
  • Query Invalidation: Invalidate queries after mutations to ensure your UI reflects the latest server state.
  • Optimistic Updates: Implement optimistic updates for a snappier user experience, especially for actions like “liking” a post.
  • Error Boundaries: Use React Error Boundaries to gracefully handle data fetching errors.

Combining Zustand and React Query

Zustand and React Query complement each other perfectly:
  • Zustand: Manages transient UI state (e.g., modal open/close, form input values before submission).
  • React Query: Manages persistent server data (e.g., lists of items, user profiles fetched from an API, authentication status from a backend).
By separating these concerns, you achieve a cleaner, more performant, and easier-to-maintain codebase.

Storage: MMKV vs Supabase

Shipnative includes MMKV for fast local storage, but it’s important to understand when to use it vs Supabase.

The Golden Rule

Supabase is for user data. MMKV is for local caching.

When to Use Supabase

Data TypeExampleWhy Supabase
User profilesName, avatar, bioSyncs across devices
User preferencesTheme, notificationsAvailable on any device
User contentPosts, messages, notesNeeds backup, sharing
RelationshipsFriends, followersRelational data with RLS

When to Use MMKV

Data TypeExampleWhy MMKV
UI cacheLast subscription statusFast loading before server responds
Local-only stateLast opened tabDevice-specific
Draft contentUnsaved form dataTemporary until user saves

Example: User Preferences

// ❌ WRONG - Preferences won't sync across devices
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { mmkvStorage } from '@/stores/authConstants'

const useSettingsStore = create(
  persist(
    (set) => ({
      darkMode: false,
      setDarkMode: (value) => set({ darkMode: value }),
    }),
    { name: 'settings', storage: mmkvStorage }
  )
)

// ✅ CORRECT - Store in Supabase, preferences sync everywhere
import { supabase } from '@/services/supabase'

// Save preference
await supabase
  .from('profiles')
  .update({ dark_mode_enabled: true })
  .eq('id', userId)

// Load preference
const { data } = await supabase
  .from('profiles')
  .select('dark_mode_enabled')
  .eq('id', userId)
  .single()

What Shipnative Uses MMKV For

The boilerplate uses MMKV sparingly for caching:
  1. Subscription status cache - RevenueCat is the source of truth; MMKV provides instant UI feedback
  2. Onboarding progress cache - Supabase profiles is the source of truth; MMKV speeds up the UI
Never store sensitive data (auth tokens, passwords, PII) in MMKV. Supabase Auth handles secure token storage automatically.

Resources