A Store with Zustand

A small global store outside the tree, with selectors so a component re-renders only for the slice it actually reads.

Syntaxconst useStore = create<State>()((set, get) => ({ ...initialState, ...actions }))

Zustand is a store that lives outside the component tree. create takes a function receiving set and get, and returns the initial state together with the functions that change it. What create gives you back is a hook you can call in any component. There is no provider to wrap the app in.

Selectors

Pass a selector and the component subscribes only to what that selector returns. useCartStore((state) => state.count) re-renders when the count changes and ignores every other write to the store. Call the hook with no argument and you subscribe to the whole store, so any change re-renders you. Results are compared with Object.is, which makes primitives cheap and a freshly built object or array always unequal.

Writing

set merges at the top level: set({ token: null }) leaves the rest of the state untouched. The updater form uses a concise arrow body, where the parentheses are required: set((state) => ({ count: state.count + 1 })) is the same code as set((state) => { return { count: state.count + 1 }; }). Outside React, useStore.getState() and useStore.setState() work in any plain module.

When a store beats context

When the value changes often, or when only a few deep components read it. A context value change re-renders every consumer; a selector re-renders only the components whose slice moved.

Example

Example · tsx
// A centred screen: a large number above an "Add to cart" button. Each tap raises the
// number the first time a product is added, and leaves it alone after that.
import { Pressable, StyleSheet, Text, View } from "react-native";
import { create } from "zustand";

type CartState = {
  items: Record<string, number>;
  add: (sku: string) => void;
  clear: () => void;
};

// create<CartState>()(...) is the curried form TypeScript needs: the state type cannot be
// inferred from the initialiser, so you pass it. Untyped JavaScript writes create((set) => ...),
// which is the same function.
const useCartStore = create<CartState>()((set) => ({
  items: {},
  add: (sku) =>
    set((state) => ({ items: { ...state.items, [sku]: (state.items[sku] ?? 0) + 1 } })),
  clear: () => set({ items: {} }), // set merges at the top level, it does not replace the state
}));

function CartBadge() {
  // Selector: a number, so this re-renders only when the number changes.
  const distinctItems = useCartStore((state) => Object.keys(state.items).length);
  return <Text style={styles.badge}>{distinctItems}</Text>;
}

export default function ProductScreen() {
  // Actions keep the same identity for the life of the store, so this never re-renders.
  const add = useCartStore((state) => state.add);
  return (
    <View style={styles.screen}>
      <CartBadge />
      <Pressable onPress={() => add("sku-1")} style={styles.button}>
        <Text>Add to cart</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
  badge: { fontSize: 24, fontWeight: "600" },
  button: { paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, backgroundColor: "#e5e5e5" },
});

When to use it

  • Hold the signed-in session token so both screens and the API client read the same value.
  • Keep a cart or a draft that several unrelated screens add to, with no common parent to lift state into.
  • Store user preferences, such as compact rows or a chosen unit, read in a dozen places and written in one.
  • Replace a near-root context whose value changes on every keystroke and re-renders half the app.

More examples

Reading several fields with useShallow

On screen: two lines summarising the active filters, updating when either of those two filters changes and staying put when the sort order changes. Reach for useShallow when one component needs three or four fields and separate selector calls would be noise; without it, the object literal is new on every store change and the component re-renders constantly.

Example · tsx
import { StyleSheet, Text, View } from "react-native";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";

type FilterState = {
  query: string;
  onlyUnread: boolean;
  sort: "new" | "old";
  setQuery: (query: string) => void;
};

const useFilterStore = create<FilterState>()((set) => ({
  query: "",
  onlyUnread: false,
  sort: "new",
  setQuery: (query) => set({ query }),
}));

export default function FilterSummary() {
  // useShallow compares the returned object's values, not its identity, so changing `sort`
  // alone does not re-render. Equivalent longhand, two subscriptions instead of one:
  //   const query = useFilterStore((state) => state.query);
  //   const onlyUnread = useFilterStore((state) => state.onlyUnread);
  const { query, onlyUnread } = useFilterStore(
    useShallow((state) => ({ query: state.query, onlyUnread: state.onlyUnread }))
  );

  return (
    <View style={styles.box}>
      <Text>{query === "" ? "No search term" : `Searching ${query}`}</Text>
      <Text>{onlyUnread ? "Unread only" : "All items"}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { padding: 16, gap: 4 },
});

Using the store outside React

No UI at all. This is the main thing a store can do that context cannot: non-component code, such as an API client or a notification handler, reads and writes the same state the screens use. getState is a snapshot with no subscription, so it is safe in a module, and the value it returns is whatever the last write left.

Example · tsx
// store/session.ts
import { create } from "zustand";

type SessionState = {
  token: string | null;
  setToken: (token: string | null) => void;
};

export const useSessionStore = create<SessionState>()((set) => ({
  token: null,
  setToken: (token) => set({ token }),
}));

// api/client.ts — a plain module, not a component, so it cannot call hooks.
export async function authedFetch(path: string): Promise<Response> {
  const { token } = useSessionStore.getState(); // reads the current state, no subscription
  return fetch(`https://api.example.com${path}`, {
    headers: token === null ? {} : { Authorization: `Bearer ${token}` },
  });
}

// Writing from outside the tree, for example from an interceptor on a 401 response:
export function signOut(): void {
  useSessionStore.setState({ token: null });
}

// Reacting to changes outside the tree. subscribe returns the unsubscribe function.
export function watchSession(): () => void {
  return useSessionStore.subscribe((state) => {
    console.log("signed in:", state.token !== null);
  });
}

Deriving instead of storing

A plain function with no React in it, which is why it can be unit tested directly. Keeping the total out of the store removes a whole class of bug where items and total drift apart, and because the function returns a primitive it is a good selector: the component re-renders when the number moves, not when any item object is replaced.

Example · tsx
type CartItems = Record<string, number>;
type Catalog = Record<string, { priceCents: number }>;

// A derived value is computed from the store, never kept in it as a second field that
// can disagree with the first.
export function selectTotalCents(items: CartItems, catalog: Catalog): number {
  return Object.entries(items).reduce(
    (total, [sku, quantity]) => total + (catalog[sku]?.priceCents ?? 0) * quantity,
    0
  );
}

const catalog: Catalog = { "sku-1": { priceCents: 1250 }, "sku-2": { priceCents: 400 } };

console.log(selectTotalCents({ "sku-1": 2, "sku-2": 1 }, catalog));
// Output: 2900

console.log(selectTotalCents({}, catalog));
// Output: 0

// In a component the selector returns a number, so Object.is comparison works and the
// component re-renders only when the total actually changes:
//   const total = useCartStore((state) => selectTotalCents(state.items, catalog));

Discussion

  • Be the first to comment on this lesson.