Using Zustand in React Native: Lightweight State Management Done Right

by Didin J. on Oct 04, 2025 Using Zustand in React Native: Lightweight State Management Done Right

Learn how to use Zustand for efficient state management in React Native with TypeScript. Lightweight, fast, and easy to scale with slices and middleware.

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 to count. It will re-render only when the counter changes.

  • CounterControls subscribes only to actions, so it won’t re-render when the count 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

  1. Open your app in Expo.

  2. Increment the counter.

  3. Restart the app → the value should persist.

  4. 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:

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:

Happy coding! 💙