An extension for Zustand that auto-generates type-safe actions, selectors, and hooks for your state. Built with TypeScript and React in mind.
- Auto-generated type-safe hooks for each state field
- Simple patterns:
store.get('name')
andstore.set('name', value)
- Extend your store with computed values using
extendSelectors
- Add reusable actions with
extendActions
- Built-in support for devtools, persist, immer, and mutative
Built on top of zustand
, zustand-x
offers a better developer experience with less boilerplate. Create and interact with stores faster using a more intuitive API.
Looking for React Context-based state management instead of global state? Check out Jotai X - same API, different state model.
pnpm add zustand-x
You'll also need react
and zustand
installed.
Here's how to create a simple store:
import { createStore, useStoreState, useStoreValue } from 'zustand-x';
// Create a store with an initial state
const repoStore = createStore({
name: 'ZustandX',
stars: 0,
});
// Use it in your components
function RepoInfo() {
const name = useStoreValue(repoStore, 'name');
const stars = useStoreValue(repoStore, 'stars');
return (
<div>
<h1>{name}</h1>
<p>{stars} stars</p>
</div>
);
}
function AddStarButton() {
const [, setStars] = useStoreState(repoStore, 'stars');
return <button onClick={() => setStars((s) => s + 1)}>Add star</button>;
}
The store is where everything begins. Configure it with type-safe middleware:
import { createStore } from 'zustand-x';
// Types are inferred, including middleware options
const userStore = createStore(
{
name: 'Alice',
loggedIn: false,
},
{
name: 'user',
devtools: true, // Enable Redux DevTools
persist: true, // Persist to localStorage
mutative: true, // Enable immer-style mutations
}
);
Available middleware options:
{
name: string;
devtools?: boolean | DevToolsOptions;
persist?: boolean | PersistOptions;
immer?: boolean | ImmerOptions;
mutative?: boolean | MutativeOptions;
}
The API is designed to be intuitive. Here's how you work with state:
// Get a single value
store.get('name'); // => 'Alice'
// Get the entire state
store.get('state');
// Call a selector with arguments
store.get('someSelector', 1, 2);
// Set a single value
store.set('name', 'Bob');
// Call an action
store.set('someAction', 10);
// Update multiple values at once
store.set('state', (draft) => {
draft.name = 'Bob';
draft.loggedIn = true;
});
Subscribe to a single value or selector. Optionally pass an equality function for custom comparison:
const name = useStoreValue(store, 'name');
// With selector arguments
const greeting = useStoreValue(store, 'greeting', 'Hello');
// With custom equality function for arrays/objects
const items = useStoreValue(
store,
'items',
(a, b) => a.length === b.length && a.every((item, i) => item.id === b[i].id)
);
Get a value and its setter, just like useState
. Perfect for form inputs:
function UserForm() {
const [name, setName] = useStoreState(store, 'name');
const [email, setEmail] = useStoreState(store, 'email');
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</form>
);
}
Subscribe to a value with minimal re-renders. Perfect for large objects where you only use a few fields:
function UserEmail() {
// Only re-renders when user.email changes
const user = useTracked(store, 'user');
return <div>{user.email}</div>;
}
function UserAvatar() {
// Only re-renders when user.avatar changes
const user = useTracked(store, 'user');
return <img src={user.avatar} />;
}
Get the entire store with tracking.
function UserProfile() {
// Only re-renders when accessed fields change
const state = useTrackedStore(store);
return (
<div>
<h1>{state.user.name}</h1>
<p>{state.user.bio}</p>
{state.isAdmin && <AdminPanel />}
</div>
);
}
Selectors help you derive new values from your state. Chain them together to build complex computations:
const store = createStore(
{ firstName: 'Jane', lastName: 'Doe' },
{ mutative: true }
);
const extendedStore = store
.extendSelectors(({ get }) => ({
fullName: () => get('firstName') + ' ' + get('lastName'),
}))
.extendSelectors(({ get }) => ({
fancyTitle: (prefix: string) => prefix + get('fullName').toUpperCase(),
}));
// Using them
extendedStore.get('fullName'); // => 'Jane Doe'
extendedStore.get('fancyTitle', 'Hello '); // => 'Hello JANE DOE'
Use them in components:
function Title() {
const fancyTitle = useStoreValue(extendedStore, 'fancyTitle', 'Welcome ')
return <h1>{fancyTitle}</h1>
}
Actions are functions that modify state. They can read or write state and even compose with other actions:
const storeWithActions = store.extendActions(
({ get, set, actions: { someActionToOverride } }) => ({
updateName: (newName: string) => set('name', newName),
resetState: () => {
set('state', (draft) => {
draft.firstName = 'Jane';
draft.lastName = 'Doe';
});
},
someActionToOverride: () => {
// You could call the original if you want:
// someActionToOverride()
// then do more stuff...
},
})
);
// Using actions
storeWithActions.set('updateName', 'Julia');
storeWithActions.set('resetState');
Each middleware can be enabled with a simple boolean or configured with options:
const store = createStore(
{ name: 'ZustandX', stars: 10 },
{
name: 'repo',
devtools: { enabled: true }, // Redux DevTools with options
persist: { enabled: true }, // localStorage with options
mutative: true, // shorthand for { enabled: true }
}
);
Access the underlying Zustand store when needed:
// Use the original Zustand hook
const name = useZustandStore(store, (state) => state.name);
// Get the vanilla store
const vanillaStore = store.store;
vanillaStore.getState();
vanillaStore.setState({ count: 1 });
// Subscribe to changes
const unsubscribe = vanillaStore.subscribe((state) =>
console.log('New state:', state)
);
// zustand
import create from 'zustand'
const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
// Computed values need manual memoization
double: 0,
setDouble: () => set((state) => ({ double: state.count * 2 }))
}))
// Component
const count = useStore((state) => state.count)
const increment = useStore((state) => state.increment)
const double = useStore((state) => state.double)
// zustand-x
import { createStore, useStoreValue, useStoreState } from 'zustand-x'
const store = createStore({ count: 0 })
.extendSelectors(({ get }) => ({
// Computed values are auto-memoized
double: () => get('count') * 2
}))
.extendActions(({ set }) => ({
increment: () => set('count', (count) => count + 1),
}))
// Component
const count = useStoreValue(store, 'count')
const double = useStoreValue(store, 'double')
const increment = () => store.set('increment')
Key differences:
- No need to create selectors manually - they're auto-generated for each state field
- Direct access to state fields without selector functions
- Simpler action definitions with
set('key', value)
pattern - Type-safe by default without extra type annotations
- Computed values are easier to define and auto-memoized with
extendSelectors
// Before
store.use.name();
store.get.name();
store.set.name('Bob');
// Now
useStoreValue(store, 'name');
store.get('name');
store.set('name', 'Bob');
// With selectors and actions
// Before
store.use.someSelector(42);
store.set.someAction(10);
// Now
useStoreValue(store, 'someSelector', 42);
store.set('someAction', 10);