State Management: Redux & Zustand
The Context API solves prop drilling well for state that rarely changes, but it wasn't designed to be a complete state-management system — no fine-grained subscriptions, no middleware, no dedicated developer tools. When an app's shared state grows large, changes often, or is touched by many independent parts of the UI, most React Native teams reach for a dedicated library. This lesson covers the two most popular: Redux Toolkit, the modern, official way to use Redux, and Zustand, a newer, much lighter-weight alternative that has rapidly grown in popularity.
Redux Toolkit: A Predictable, Centralized Store
Classic Redux (the original library, from years ago) required a lot of hand-written boilerplate —
action type constants, action creator functions, and switch-statement reducers, often duplicated
across features. Redux Toolkit (commonly imported as @reduxjs/toolkit)
is the officially recommended way to write Redux today, and it eliminates almost all of that
boilerplate while keeping Redux's core idea intact.
That core idea is simple to state: your entire app's shared state lives in one single store, and the only way to change anything in that store is by dispatching an action — a plain object describing "what happened." Components never mutate the store directly; they dispatch an action, and a reducer function decides how the store should change in response. This one-way flow is what makes Redux apps predictable, even as they grow large: every state change can be traced back to a specific, named action.
A Slice: Bundling State and Reducers Together
Redux Toolkit's createSlice bundles a piece of state, its initial value, and the
reducer functions that can change it, all in one place — no separate action-type constants or
switch statements required:
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: [],
reducers: {
addItem: (state, action) => {
// Redux Toolkit uses Immer under the hood, so "mutating"
// state here is actually safe and produces an immutable update
state.push(action.payload);
},
removeItem: (state, action) => {
return state.filter(item => item.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
The store itself is assembled from one or more slice reducers with configureStore,
usually once, near the root of the app:
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});
And the whole app is wrapped once in a Provider from react-redux,
connecting the store to the component tree — conceptually similar to the Context
Provider from the previous lesson, but purpose-built for Redux:
import { Provider } from 'react-redux';
import { store } from './store';
function App() {
return (
<Provider store={store}>
<RootNavigator />
</Provider>
);
}
Connecting a Component: useSelector and useDispatch
Inside any component, useSelector reads a piece of state from the store, and
useDispatch gives you the function used to dispatch actions:
import React from 'react';
import { View, Text, Button } from 'react-native';
import { useSelector, useDispatch } from 'react-redux';
import { addItem } from './cartSlice';
function ProductScreen() {
const cartItems = useSelector(state => state.cart);
const dispatch = useDispatch();
return (
<View>
<Text>Cart has {cartItems.length} item(s)</Text>
<Button
title="Add running shoes"
onPress={() => dispatch(addItem({ id: 'p_1', name: 'Running Shoes' }))}
/>
</View>
);
}
useSelector only re-renders this component when the specific slice it reads (
state.cart) actually changes — a more precise, fine-grained subscription than a
plain Context consumer gets, which is part of why Redux scales better to large, frequently
updated state.
Zustand: A Much Lighter Alternative
Zustand ("state" in German) takes almost the opposite approach: no slices, no
reducers, no Provider wrapping your app, no action objects to dispatch. A single
create() call defines your state and the functions that update it, together, in one
place:
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
addItem: (product) =>
set((state) => ({ items: [...state.items, product] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter(item => item.id !== id) })),
}));
That single useCartStore value is a custom hook — no Provider needed
anywhere. Any component simply imports it and calls it directly, picking out just the piece of
state or the action function it needs:
import React from 'react';
import { View, Text, Button } from 'react-native';
import { useCartStore } from './cartStore';
function ProductScreen() {
const items = useCartStore(state => state.items);
const addItem = useCartStore(state => state.addItem);
return (
<View>
<Text>Cart has {items.length} item(s)</Text>
<Button
title="Add running shoes"
onPress={() => addItem({ id: 'p_1', name: 'Running Shoes' })}
/>
</View>
);
}
Compare this directly to the Redux Toolkit version above: same cart feature, same
addItem behavior — but no slice file, no configureStore, no
Provider, and no dispatched action objects. This dramatically smaller footprint is
exactly why Zustand has become so popular for small-to-medium apps and teams who want shared
state without Redux's ceremony.
Decision Guide: Which Approach Should You Use?
Between local useState, Context, Zustand, and Redux Toolkit, there's no single
"correct" answer — each is the right tool for a different situation:
| Approach | Best for |
|---|---|
| useState | Simple state that lives inside a single component and doesn't need to be shared |
| Context API | Simple state shared across many components, that changes infrequently — theme, auth, language |
| Zustand | Lightweight shared state with minimal boilerplate; a fast-growing favorite for small-to-medium apps and state that updates often |
| Redux Toolkit | The most structured, tooling-rich option — Redux DevTools, middleware, strict conventions — often favored on larger teams and apps with genuinely complex, interconnected state |
useState. Reach for Context when
multiple distant components need the same rarely-changing value. Reach for Zustand when you need
real shared state that updates often but want to avoid Redux's setup. Reach for Redux Toolkit
when the app (or team) is large enough that dedicated dev tools, middleware, and strict
conventions genuinely pay for themselves.