← Back to Lessons
  • swift

  • kotlin

  • zustand

  • react native

  • mobile

  • React Navigation

  • state-management

Zustand: Global State in React Native Without the Drama

What problem does Zustand solve?
  • Installation

If you come from native mobile development (Kotlin, Swift), you know that choosing your state management tool is crucial. On Android you have ViewModel + StateFlow, on iOS SwiftUI with @StateObject. In React Native, Zustand has become the favorite choice for a simple reason: it works and doesn't complicate your life.

What problem does Zustand solve?

Imagine you're building an e-commerce app. You need:

  • Know who's logged in (user)
  • What products they have in the cart
  • Their preferences (light/dark theme, language)

This state needs to be accessible from multiple screens. The question is: how do you share it?

Context API is React's native solution, but it has a serious problem: every time something changes in the context, all components that use it re-render. Even if they only need a small part of the state. Zustand solves this with smart selectors.

Installation

1npm install zustand

Only 1KB. Less than an average UI component.

Your first store

If you come from Kotlin or Swift, a store is similar to a ViewModel (Android) or an ObservableObject (SwiftUI). It's a class that maintains your application's state and exposes methods to modify it. The key difference: Instead of creating a class, you use a function that returns an object with your state and methods.

1// store/useStore.js 2import { create } from 'zustand'; 3 4export const useStore = create((set) => ({ 5 // Initial state - like the properties of your ViewModel/ObservableObject 6 user: null, 7 cart: [], 8 9 // Actions - like the public methods of your ViewModel 10 login: (user) => set({ user }), 11 12 logout: () => set({ user: null, cart: [] }), 13 14 addToCart: (product) => set((state) => ({ 15 cart: [...state.cart, product] 16 })), 17 18 removeFromCart: (productId) => set((state) => ({ 19 cart: state.cart.filter(item => item.id !== productId) 20 })) 21}));

The advantage of Zustand is that the component only re-renders when cart changes. If user changes, this component doesn't notice. In ViewModel and ObservableObject you would have to manage this manually with selectors or computed properties.

Using the store in components

1// screens/CartScreen.js 2export default function CartScreen() { 3 const cart = useStore((state) => state.cart); 4 const removeFromCart = useStore((state) => state.removeFromCart); 5 6 return ( 7 <View> 8 <Text>Cart ({cart.length} items)</Text> 9 <FlatList 10 data={cart} 11 keyExtractor={(item) => item.id} 12 renderItem={({ item }) => ( 13 <View> 14 <Text>{item.name}</Text> 15 <TouchableOpacity onPress={() => removeFromCart(item.id)}> 16 <Text>Remove</Text> 17 </TouchableOpacity> 18 </View> 19 )} 20 /> 21 </View> 22 ); 23}
1// components/UserProfile.js 2export default function UserProfile() { 3 const user = useStore((state) => state.user); 4 5 if (!user) return null; 6 7 return ( 8 <View> 9 <Text>{user.name}</Text> 10 <Text>{user.email}</Text> 11 </View> 12 ); 13}

Note something important: UserProfile only re-renders when user changes. Changes in cart don't affect it. This is automatic performance.

The magic of selectors

A selector is a function that extracts only the part of the state you need:

1// Only updates when cart.length changes 2const cartCount = useStore((state) => state.cart.length); 3 4// Only when the user's name changes 5const userName = useStore((state) => state.user?.name); 6 7// Computed selector: calculates the total 8const total = useStore((state) => 9 state.cart.reduce((sum, item) => sum + item.price, 0) 10);

Each selector is an independent subscription. Zustand compares the previous result with the new one, and only updates the component if it changed.

The shallow comparison problem

JavaScript compares objects by reference, not by content. This causes a common problem:

1// ❌ BAD: Always re-renders 2function CartSummary() { 3 const { cart, user } = useStore((state) => ({ 4 cart: state.cart, 5 user: state.user 6 })); 7 // Always returns a new object { cart, user } 8}

The solution is to use shallow to compare internal properties:

1// ✅ GOOD: Only updates if cart or user change 2function CartSummary() { 3 const { cart, user } = useStore( 4 (state) => ({ 5 cart: state.cart, 6 user: state.user 7 }), 8 shallow 9 ); 10 11 const total = cart.reduce((sum, item) => sum + item.price, 0); 12 13 return ( 14 <View> 15 <Text>{user?.name}</Text> 16 <Text>{cart.length} items - ${total}</Text> 17 </View> 18 ); 19}

Actions with complex logic

You can access the current state within actions using get:

1export const useStore = create((set, get) => ({ 2 products: [], 3 cart: [], 4 5 addToCart: (productId) => { 6 const state = get(); 7 const product = state.products.find(p => p.id === productId); 8 9 if (!product) { 10 console.error('Product not found'); 11 return; 12 } 13 14 const existsInCart = state.cart.some(item => item.id === productId); 15 16 if (existsInCart) { 17 // Increment quantity 18 set((state) => ({ 19 cart: state.cart.map(item => 20 item.id === productId 21 ? { ...item, quantity: item.quantity + 1 } 22 : item 23 ) 24 })); 25 } else { 26 // Add new 27 set((state) => ({ 28 cart: [...state.cart, { ...product, quantity: 1 }] 29 })); 30 } 31 } 32}));

Integration with React Navigation

Zustand works perfectly with React Navigation without special configuration:

1// navigation/AppNavigator.js 2export default function AppNavigator() { 3 const isAuthenticated = useStore((state) => state.user !== null); 4 5 return ( 6 <NavigationContainer> 7 <Stack.Navigator> 8 {isAuthenticated ? ( 9 <> 10 <Stack.Screen name="Home" component={HomeScreen} /> 11 <Stack.Screen name="Cart" component={CartScreen} /> 12 <Stack.Screen name="Profile" component={ProfileScreen} /> 13 </> 14 ) : ( 15 <> 16 <Stack.Screen name="Login" component={LoginScreen} /> 17 <Stack.Screen name="Register" component={RegisterScreen} /> 18 </> 19 )} 20 </Stack.Navigator> 21 </NavigationContainer> 22 ); 23}

If you need to navigate from a store action, pass the navigation reference:

1export const useStore = create((set) => ({ 2 user: null, 3 4 loginAndNavigate: async (credentials, navigation) => { 5 try { 6 const response = await fetch('/api/login', { 7 method: 'POST', 8 body: JSON.stringify(credentials) 9 }); 10 const user = await response.json(); 11 12 set({ user }); 13 navigation.replace('Home'); 14 } catch (error) { 15 console.error('Login failed:', error); 16 } 17 } 18})); 19 20// In your LoginScreen 21function LoginScreen({ navigation }) { 22 const loginAndNavigate = useStore((state) => state.loginAndNavigate); 23 24 const handleLogin = () => { 25 loginAndNavigate({ email, password }, navigation); 26 }; 27}

Persistence with AsyncStorage

To save state between app sessions:

1npm install async-storage
1export const useStore = create( 2 persist( 3 (set, get) => ({ 4 user: null, 5 theme: 'light', 6 cart: [], 7 8 login: (user) => set({ user }), 9 toggleTheme: () => set((state) => ({ 10 theme: state.theme === 'light' ? 'dark' : 'light' 11 })) 12 }), 13 { 14 name: 'app-storage', 15 storage: createJSONStorage(() => AsyncStorage), 16 17 // Only persist some properties 18 partialize: (state) => ({ 19 user: state.user, 20 theme: state.theme 21 // cart is NOT persisted (it's temporary) 22 }) 23 } 24 ) 25);

Now user and theme are saved automatically. When the user opens the app again, the state is restored.

Organization for large apps

For small to medium apps, a single store works perfectly. But if your store goes beyond 300-400 lines, you can divide it into slices:

1// store/slices/authSlice.js 2export const createAuthSlice = (set) => ({ 3 user: null, 4 isAuthenticated: false, 5 6 login: (user) => set({ user, isAuthenticated: true }), 7 logout: () => set({ user: null, isAuthenticated: false }) 8}); 9 10// store/slices/cartSlice.js 11export const createCartSlice = (set) => ({ 12 cart: [], 13 14 addToCart: (product) => set((state) => ({ 15 cart: [...state.cart, product] 16 })), 17 18 clearCart: () => set({ cart: [] }) 19}); 20 21// store/useStore.js 22export const useStore = create((...args) => ({ 23 ...createAuthSlice(...args), 24 ...createCartSlice(...args) 25}));

Why Zustand works in React Native

React Native has strict performance requirements, so you need to maintain constant 60fps or the app feels slow.

Zustand helps with this through granular updates, meaning only components subscribed to a specific part of the state update, no cascading re-renders, minimal overhead (1KB of code is practically nothing) and automatic optimization.

Conclusion

Zustand gives you global state without the drama of Context API or the boilerplate of Redux. It's especially good for React Native where performance matters.

When to use Zustand:

  • Shared state between multiple screens
  • User data and authentication
  • Carts, favorites, settings
  • Any data you need in multiple places

When NOT to use Zustand:

  • Local state of a single component (use useState)
  • Form state (use react-hook-form)
  • Server state (use react-query or SWR)