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
- 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 })),
}))
- 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
- 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
}
- 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 Type | Example | Why Supabase |
|---|
| User profiles | Name, avatar, bio | Syncs across devices |
| User preferences | Theme, notifications | Available on any device |
| User content | Posts, messages, notes | Needs backup, sharing |
| Relationships | Friends, followers | Relational data with RLS |
When to Use MMKV
| Data Type | Example | Why MMKV |
|---|
| UI cache | Last subscription status | Fast loading before server responds |
| Local-only state | Last opened tab | Device-specific |
| Draft content | Unsaved form data | Temporary 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:
- Subscription status cache - RevenueCat is the source of truth; MMKV provides instant UI feedback
- 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