When building React Native applications, managing state efficiently is one of the biggest challenges. While libraries like Redux and MobX have been the traditional go-to solutions, they often come with boilerplate code, steep learning curves, and performance overhead.
This is where Zustand comes in—a lightweight, minimal, and scalable state management library for React and React Native. Its API is intuitive, requiring just a few lines of code to set up global state, making it ideal for small to medium-sized apps while still being powerful enough to scale.
In this tutorial, we’ll walk through how to use Zustand in a React Native application. You’ll learn how to:
-
Set up Zustand in a fresh React Native project
-
Create and use a global store
-
Update and consume state across multiple components
-
Apply middleware for debugging and persistence
-
Follow best practices for maintainable state management
By the end of this guide, you’ll have a solid understanding of how Zustand can simplify your state management while keeping your React Native codebase clean and performant.
Prerequisites & Project Setup
Before we dive into coding with Zustand, let’s make sure we have the right tools and environment set up.
Prerequisites
To follow along with this tutorial, you should have:
-
Basic knowledge of React Native (components, props, hooks).
-
Node.js (v18+) is installed on your system.
-
React Native CLI or Expo CLI set up (we’ll use Expo in this tutorial for simplicity).
-
A working Android Emulator, iOS Simulator, or a real device.
If you’re new to React Native, check the official React Native documentation for environment setup details.
Step 1: Create a New React Native Project
We’ll use Expo to bootstrap the project:
npx create-expo-app zustand-rn-demo
cd zustand-rn-demo
Once the project is created, you can run it with:
npx expo start
This will start the Expo development server and open the app in your emulator, simulator, or Expo Go app.
Step 2: Install Zustand
Now, let’s install Zustand in our project:
npm install zustand
# or
yarn add zustand
Zustand has no peer dependencies, which makes it extremely lightweight and easy to add.
At this point, we have a working React Native project with Zustand installed. In the next section, we’ll create our first Zustand store and learn how to consume it in components.
Creating the First Zustand Store
Zustand makes creating a global state store incredibly simple. Unlike Redux, you don’t need reducers, actions, or complex boilerplate—just a function that returns your state and actions.
Step 1: Create a Store File
Inside your project, create a new folder called store
(to keep things organized) and add a file named useCounterStore.ts
:
// store/useCounterStore.ts
import { create } from 'zustand';
type CounterState = {
count: number;
increase: () => void;
decrease: () => void;
reset: () => void;
};
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
How it works:
-
count
→ the state value. -
increase
,decrease
,reset
→ actions (functions) that update the state. -
set
→ Zustand’s built-in function to update the state.
That’s all it takes to create a Zustand store!
Step 2: Using the Store in a Component
Open your app/(tabs)/index.tsx
and import the store:
import { Button, StyleSheet, Text, View } from "react-native";
import useCounterStore from "@/store/useCounterStore";
export default function HomeScreen() {
const { count, increase, decrease, reset } = useCounterStore();
return (
<View style={styles.container}>
<Text style={styles.counterText}>Count: {count}</Text>
<Button title="Increase" onPress={increase} />
<Button title="Decrease" onPress={decrease} />
<Button title="Reset" onPress={reset} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center"
},
counterText: {
fontSize: 24,
marginBottom: 20
}
});
Step 3: Test the App
Run your app again:
npx expo start
Now you should see a simple counter app where you can increase, decrease, and reset the value—powered by Zustand! 🎉
✅ In just a few lines of code, we created and consumed a global state.
Using Selectors for Optimized State Consumption
One of the strengths of Zustand is its fine-grained state selection. Instead of re-rendering an entire component every time any part of the store changes, Zustand allows you to select only the piece of state you need.
This makes your app more performant—especially important in React Native, where unnecessary re-renders can affect UI smoothness.
Step 1: Using Selectors
Let’s update our counter example. Instead of pulling all state values and actions at once, we’ll use selectors to consume only what’s needed.
app/(tabs)/index.tsx
import useCounterStore from "@/store/useCounterStore";
import React from "react";
import { Button, StyleSheet, Text, View } from "react-native";
function CounterDisplay() {
// Only subscribe to `count`
const count = useCounterStore((state) => state.count);
return <Text style={styles.counterText}>Count: {count}</Text>;
}
function CounterControls() {
// Only subscribe to actions
const increase = useCounterStore((state) => state.increase);
const decrease = useCounterStore((state) => state.decrease);
const reset = useCounterStore((state) => state.reset);
return (
<View>
<Button title="Increase" onPress={increase} />
<Button title="Decrease" onPress={decrease} />
<Button title="Reset" onPress={reset} />
</View>
);
}
export default function HomeScreen() {
return (
<View style={styles.container}>
<CounterDisplay />
<CounterControls />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center"
},
counterText: {
fontSize: 24,
marginBottom: 20
}
});
Step 2: Why This Matters
-
CounterDisplay
subscribes only tocount
. It will re-render only when the counter changes. -
CounterControls
subscribes only to actions, so it won’t re-render when thecount
changes.
This separation ensures components update only when necessary, improving performance.
Step 3: Multiple Selectors
You can also select multiple states at once:
const { count, increase } = useCounterStore((state) => ({
count: state.count,
increase: state.increase,
}));
This way, the component re-renders only when either count
or increase
changes.
✅ Using selectors helps keep your React Native app snappy by avoiding unnecessary re-renders.
In the next section, we’ll go beyond basics and explore middleware in Zustand for features like logging, debugging, and state persistence.
Enhancing Zustand with Middleware (Logger & Persistence)
Zustand has built-in support for middleware that extends its functionality without adding complexity. Some of the most common middleware are:
-
Logger → Logs every state change (useful for debugging).
-
Persist → Saves state to storage (e.g., AsyncStorage) so it survives app restarts.
Both are easy to set up in TypeScript.
Step 1: Logger Middleware
First, let’s add a logger to our counter store. This will print state changes in the console.
// store/useCounterStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
type CounterState = {
count: number;
increase: () => void;
decrease: () => void;
reset: () => void;
};
const useCounterStore = create<CounterState>()(
devtools((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increase'),
decrease: () => set((state) => ({ count: state.count - 1 }), false, 'counter/decrease'),
reset: () => set({ count: 0 }, false, 'counter/reset'),
}))
);
export default useCounterStore;
Here’s what happens:
-
We wrap the store in
devtools
middleware. -
Each action gets a custom label (e.g.,
"counter/increase"
). -
Now you can track state updates in the console or in Redux DevTools if installed.
Step 2: Persist Middleware
Next, let’s persist the counter value across app reloads. For React Native, we’ll use AsyncStorage.
Install AsyncStorage:
npm install @react-native-async-storage/async-storage
Update the store:
// store/useCounterStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
type CounterState = {
count: number;
increase: () => void;
decrease: () => void;
reset: () => void;
};
const useCounterStore = create<CounterState>()(
devtools(
persist(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increase'),
decrease: () => set((state) => ({ count: state.count - 1 }), false, 'counter/decrease'),
reset: () => set({ count: 0 }, false, 'counter/reset'),
}),
{
name: 'counter-storage', // storage key
storage: {
getItem: async (key) => {
const value = await AsyncStorage.getItem(key);
return value ? JSON.parse(value) : null;
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
},
}
)
)
);
export default useCounterStore;
Now, if you increment the counter and restart the app, the value will be restored automatically 🎉.
Step 3: Testing Middleware
-
Open your app in Expo.
-
Increment the counter.
-
Restart the app → the value should persist.
-
Check your console → you should see logs for every action.
✅ With just a few lines of code, we’ve made our Zustand store more powerful by adding logging and persistence.
Scaling Zustand with Slices (Modular State Management)
For small apps, a single Zustand store is often enough. But as your React Native app grows, you’ll likely need to manage multiple pieces of state (e.g., user authentication, UI theme, settings, API data).
Instead of cramming everything into one store, you can organize your state using slices—modular store pieces that are combined into a single store.
This approach keeps your code clean, maintainable, and scalable.
Step 1: Create Slice Types
Let’s say we want to manage two concerns:
-
CounterSlice → our counter state.
-
ThemeSlice → dark/light mode toggle.
We’ll start by defining slice types.
// store/types.ts
export type CounterSlice = {
count: number;
increase: () => void;
decrease: () => void;
reset: () => void;
};
export type ThemeSlice = {
theme: 'light' | 'dark';
toggleTheme: () => void;
};
Step 2: Define Slice Creators
Each slice is just a function that returns part of the state.
// store/counterSlice.ts
import { StateCreator } from 'zustand';
import { CounterSlice } from './types';
export const createCounterSlice: StateCreator<CounterSlice> = (set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
});
// store/themeSlice.ts
import { StateCreator } from 'zustand';
import { ThemeSlice } from './types';
export const createThemeSlice: StateCreator<ThemeSlice> = (set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
});
Step 3: Combine Slices into a Store
Now we combine multiple slices into one store.
// store/useAppStore.ts
import { create } from 'zustand';
import { createCounterSlice } from './counterSlice';
import { createThemeSlice } from './themeSlice';
import { CounterSlice, ThemeSlice } from './types';
type AppState = CounterSlice & ThemeSlice;
const useAppStore = create<AppState>()((...a) => ({
...createCounterSlice(...a),
...createThemeSlice(...a),
}));
export default useAppStore;
Step 4: Using the Store in Screens
Now you can access both counter and theme state from any screen.
// app/(tabs)/TabTwoScreen.tsx
import useAppStore from "@/store/useAppStore";
import React from "react";
import { Button, StyleSheet, Text, View } from "react-native";
export default function TabTwoScreen() {
const { count, increase, decrease, reset } = useAppStore((state) => ({
count: state.count,
increase: state.increase,
decrease: state.decrease,
reset: state.reset
}));
const theme = useAppStore((state) => state.theme);
const toggleTheme = useAppStore((state) => state.toggleTheme);
return (
<View style={styles.container}>
<Text style={styles.counterText}>Count: {count}</Text>
<Button title="Increase" onPress={increase} />
<Button title="Decrease" onPress={decrease} />
<Button title="Reset" onPress={reset} />
<Text style={styles.counterText}>Theme: {theme}</Text>
<Button title="Toggle Theme" onPress={toggleTheme} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center"
},
counterText: {
fontSize: 20,
marginVertical: 10
}
});
✅ With slices, you can keep your store organized and modular. Each slice handles its own state and logic, but the final store combines everything into a single source of truth.
Best Practices for Zustand in React Native
Zustand is simple and powerful, but like any state management tool, it can be misused if not structured properly. Follow these best practices to keep your React Native project fast, maintainable, and easy to extend.
🧩 1. Use Local State Where Appropriate
Not everything needs to go into Zustand.
If a piece of state is used only inside one component (like a modal toggle or a form input), use React’s useState
or useReducer
.
Use Zustand only for:
-
Shared state across multiple components or screens.
-
Persisted data that should survive reloads.
-
Global configurations (theme, auth status, etc.).
// ✅ Use React local state for isolated logic
const [isVisible, setIsVisible] = useState(false);
⚡ 2. Use Selectors to Prevent Unnecessary Re-renders
Always subscribe to specific pieces of state instead of pulling the entire store. This minimizes re-renders and improves performance on mobile devices.
// ✅ Good
const count = useAppStore((state) => state.count);
// ❌ Avoid
const { count, increase, decrease } = useAppStore();
If you need multiple values, use object destructuring inside the selector:
const { count, increase } = useAppStore((state) => ({
count: state.count,
increase: state.increase,
}));
🧱 3. Organize Stores into Slices for Larger Apps
For complex apps, modularize your store by feature (e.g., authSlice
, themeSlice
, userSlice
).
This keeps your logic cohesive and avoids bloated store files.
// store/useAppStore.ts
const useAppStore = create<AppState>()((...a) => ({
...createAuthSlice(...a),
...createThemeSlice(...a),
...createSettingsSlice(...a),
}));
🔄 4. Combine Middleware for Better Developer Experience
Middleware can be layered to add logging, persistence, and even undo/redo functionality.
Commonly used ones include:
-
devtools
— integrates with Redux DevTools for debugging. -
persist
— saves state to AsyncStorage. -
subscribeWithSelector
— reacts to specific store changes.
const useStore = create(
devtools(
persist(
(set) => ({ /* state here */ }),
{ name: 'app-storage' }
)
)
);
🧠 5. Avoid Mutating State Directly
Always use the set
function returned by Zustand. Never mutate state objects directly—it breaks React’s reactivity.
// ✅ Correct
set((state) => ({ count: state.count + 1 }));
// ❌ Incorrect
state.count += 1; // Will not trigger re-render
🧹 6. Keep Store Logic Simple
Zustand stores should contain only state and actions—not UI logic, async side effects, or network calls directly.
Instead, call APIs in your components or custom hooks, then update the store.
✅ Good:
increase: () => set((state) => ({ count: state.count + 1 })),
❌ Avoid:
increase: async () => {
const data = await fetch(...); // don't fetch inside store logic
set({ count: data.value });
}
🔍 7. Use TypeScript for Strong Typing
Zustand works beautifully with TypeScript.
Always define clear types for each slice or state structure to prevent runtime bugs and ensure better IDE autocomplete.
type CounterState = {
count: number;
increase: () => void;
};
💾 8. Keep Persistent Storage Keys Unique
When using persist
, choose unique keys for each slice or store.
This prevents different stores from overwriting each other’s data.
persist(createThemeSlice, { name: 'theme-storage' });
✅ Summary
Following these best practices ensures your Zustand setup remains lightweight, fast, and maintainable—even as your app scales.
Conclusion + Final Thoughts
In this tutorial, you learned how to use Zustand as a lightweight yet powerful state management library in a React Native app. We started by setting up a TypeScript + Tabs Expo project, then gradually integrated Zustand by creating a simple store, optimizing performance with selectors, and enhancing functionality using middleware like logger and persistence. Finally, we explored how to scale Zustand using slices for more complex applications.
Zustand stands out because it’s:
-
🪶 Lightweight — no boilerplate or complex setup.
-
⚡ Fast — fine-grained reactivity with no unnecessary re-renders.
-
🧩 Scalable — easy to extend using middleware and modular slices.
-
💡 Simple — the API feels natural and stays close to React’s mental model.
If you’re coming from Redux or Context API, you’ll find Zustand refreshingly minimal yet capable of handling complex state management with ease.
Final Thoughts
Zustand is perfect for most small to medium-sized React Native apps, and with slices and middleware, it can comfortably power large-scale apps too. Whether you’re building a productivity tool, chat app, or e-commerce app, Zustand keeps your state predictable and your code clean.
If you want to explore more:
-
Official docs: https://docs.pmnd.rs/zustand
-
Zustand GitHub: https://github.com/pmndrs/zustand
You can find the full source on our GitHub.
That's just the basics. If you need more deep learning about React Native, you can take the following cheap course:
- React Native - The Practical Guide [2025]
- The Complete React Native + Hooks Course
- The Best React Native Course 2025 (From Beginner To Expert)
- React Native: Mobile App Development (CLI) [2025]
- React - The Complete Guide 2025 (incl. Next.js, Redux)
- React Native Development Simplified [2025]
- React Native by Projects: From Basics to Pro [2025]
- Full Stack Ecommerce Mobile App: React Native & Node.js 2025
- Learning Management System Mobile App using React Native
- React Native: Advanced Concepts
Happy coding! 💙