Skip to content

Zustand

Updating deeply nested state in Zustand requires spreading every intermediate object by hand. This grows quickly with nesting depth and is easy to get wrong. data-path replaces manual spreading with a single .set() call that handles the structural clone automatically.

import { create } from "zustand";
import { path } from "data-path";
type StoreState = {
user: {
profile: { firstName: string; theme: "light" | "dark" };
};
setFirstName: (name: string) => void;
setTheme: (theme: "light" | "dark") => void;
};
const firstNamePath = path((s: StoreState) => s.user.profile.firstName);
const themePath = path((s: StoreState) => s.user.profile.theme);
const useStore = create<StoreState>((set) => ({
user: { profile: { firstName: "Alice", theme: "light" } },
setFirstName: (name) => set((state) => firstNamePath.set(state, name)),
setTheme: (theme) => set((state) => themePath.set(state, theme)),
}));

path.set(state, value) returns a structural clone in which every object on the path is replaced, but every unrelated branch keeps its reference identity. Zustand’s default selector equality is Object.is, so selectors that pick a subtree you didn’t touch see the same reference and skip re-renders; selectors that pick the touched subtree see a new reference and re-render. (Shallow equality via useShallow / useStoreWithEqualityFn is opt-in and orthogonal — data-path doesn’t require it.)

// Before — manual spreading for each level
setTheme: (theme) =>
set((state) => ({
...state,
user: {
...state.user,
profile: { ...state.user.profile, theme },
},
})),
// After — path handles the clone
setTheme: (theme) => set((state) => themePath.set(state, theme)),

When the new value depends on the current value, use .update():

type Counter = { stats: { clickCount: number } };
const clickCountPath = path((s: Counter) => s.stats.clickCount);
const useCounter = create<Counter & { increment: () => void }>((set) => ({
stats: { clickCount: 0 },
increment: () =>
set((state) => clickCountPath.update(state, (n) => (n ?? 0) + 1)),
}));

Capture the index from a selector and build the path dynamically:

type ListStore = { items: Array<{ text: string; done: boolean }> };
const useTodos = create<ListStore & { toggle: (i: number) => void }>((set) => ({
items: [],
toggle: (i) =>
set((state) =>
path((s: ListStore) => s.items[i].done).update(state, (v) => !v)
),
}));

Paths used in multiple actions can be defined at module level. The path does not hold a reference to the store, so there are no lifecycle concerns:

const profilePath = path((s: StoreState) => s.user.profile);
const firstNamePath = profilePath.to((p) => p.firstName);
const themePath = profilePath.to((p) => p.theme);