Zustand State Management
SkillFiles & storageManage global application state using Zustand — each domain gets its own store file with typed state and actions, consumed via selector-based hooks
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Zustand State Management skill
What this skill tells your AI
The instructions your AI receives, as published by cliqrelay/cliqrelay in .agents/skills/frontend/zustand-state-management/SKILL.md and read by ahel’s review.
This skill covers Zustand.
This project uses Zustand for global state management. Stores are defined with create() from zustand, organized by domain, and consumed via auto-generated hooks with selector-based subscriptions.
No providers, no context wrappers, no boilerplate. Just stores.
Folder Structure
stores/
├── session-store.ts # Session + user state
├── product-store.ts # Product state
├── subscription-store.ts # Subscription state
└── index.ts # Re-exports all stores
Each domain gets its own store file in stores/. Stores are not nested — each store is independent.
Store Definition Pattern
// stores/session-store.ts
import { create } from "zustand";
// 1. Define the state + actions interface
interface SessionState {
// State
session: Session | undefined | null;
user: User | undefined | null;
isFetchingUser: boolean | null;
// Actions
setSession: (session: Session | undefined | null) => void;
setUser: (user: User | undefined | null) => void;
setIsFetchingUser: (fetching: boolean | null) => void;
clear: () => void;
}
// 2. Create the store with create()
export const useSessionStore = create<SessionState>((set) => ({
// Initial state — use undefined for loading, null for absent
session: undefined,
user: undefined,
isFetchingUser: null,
// Actions — set() does shallow merge
setSession: (session) => set({ session }),
setUser: (user) => set({ user }),
setIsFetchingUser: (isFetchingUser) => set({ isFetchingUser }),
clear: () => set({
session: undefined,
user: undefined,
isFetchingUser: null
}),
}));
✅ DO — Name the hook use{Domain}Store:
export const useSessionStore = create<SessionState>(/* ... */);
❌ DON'T — Default export stores:
// ❌ DON'T
export default create<SessionState>()(/* ... */);
Initial State Convention
| Value | Meaning |
|---|---|
undefined | Uninitialized / still loading |
null | Absent / empty / not available |
default value | Known initial state |
// ✅ DO
session: undefined, // Haven't checked auth yet
user: null, // Checked auth, no user found
selectedCategory: "electronics", // Known default
Reading State — Selector Pattern
Always use selectors to prevent unnecessary re-renders:
// ✅ DO — Selector (only re-renders when selected value changes)
function UserProfile() {
const user = useSessionStore((state) => state.user);
return <div>{user?.fullName}</div>;
}
// ✅ DO — Multiple selectors
function ProfileActions() {
const user = useSessionStore((state) => state.user);
const setUser = useSessionStore((state) => state.setUser);
// ...
}
❌ DON'T — Destructure the entire store:
// ❌ DON'T — Causes re-render on ANY state change
function BadComponent() {
const { user, setUser, isFetchingUser } = useSessionStore();
// ...
}
Writing State — Action Pattern
Actions are defined in the store and update state via set():
// ✅ DO — Actions in the store
export const useSessionStore = create<SessionState>((set) => ({
session: undefined,
setSession: (session) => set({ session }),
}));
// Used as:
const setSession = useSessionStore((state) => state.setSession);
setSession({ id: "abc", email: "test@test.com" });
✅ DO — set() does shallow merge — only pass changed properties:
set({ session: newSession }); // Other state properties are preserved
set({ user: null }); // Only updates user
Async Actions in Stores
For async flows that update multiple state fields, define the action in the store:
// stores/subscription-store.ts
import { create } from "zustand";
import { api } from "@repo/api-client"; // or use service imports for local domains
interface SubscriptionState {
currentSubscription: Subscription | null;
isFreeTrialUsed: boolean | undefined;
loading: boolean;
setCurrentSubscription: (sub: Subscription | null) => void;
setIsFreeTrialUsed: (used: boolean | undefined) => void;
hydrate: (id: string) => Promise<void>; // Async action
}
export const useSubscriptionStore = create<SubscriptionState>((set) => ({
currentSubscription: null,
isFreeTrialUsed: undefined,
loading: false,
setCurrentSubscription: (currentSubscription) => set({ currentSubscription }),
setIsFreeTrialUsed: (isFreeTrialUsed) => set({ isFreeTrialUsed }),
hydrate: async (id: string) => {
set({ loading: true });
try {
const response = await api.subscriptions.getSubscription(id);
set({ currentSubscription: response.data.subscription, loading: false });
} catch {
set({ currentSubscription: null, loading: false });
}
},
}));
✅ DO — Async actions can call @repo/api-client or services directly
❌ DON'T — Put async logic in components — extract to store actions or custom hooks
Store Composition Through Hooks
Complex multi-store operations go in custom hooks, not stores:
// hooks/useUpdateStoreState.ts
import { api } from "@repo/api-client"; // or import server-fns for web app
import { useSessionStore } from "[project-root]/stores/session-store";
import { useSubscriptionStore } from "[project-root]/stores/subscription-store";
const useUpdateStoreState = () => {
const session = useSessionStore((state) => state.session);
const setCurrentSubscription = useSubscriptionStore((state) => state.setCurrentSubscription);
const updateCurrentSubscription = async () => {
if (session) {
const response = await api.subscriptions.getSubscription(session.id);
setCurrentSubscription(response.data.subscription);
} else {
setCurrentSubscription(null);
}
};
return { updateCurrentSubscription };
};
export default useUpdateStoreState;
✅ DO — Compose stores with @repo/api-client or server functions in custom hooks
❌ DON'T — Call service factories inside store definitions
Accessing Store Outside React
Use .getState() and .setState() for imperative access:
// ✅ DO — Read outside React
const state = useSessionStore.getState();
console.log(state.user);
// ✅ DO — Write outside React
useSessionStore.setState({ user: null });
// Example: hydrate on app boot
async function bootstrapApp() {
const user = await getCurrentUser();
useSessionStore.getState().setUser(user);
}
Rules
✅ DO
- Create one store per domain in
stores/{domain}-store.ts - Use
undefinedfor uninitialized,nullfor absent - Use selectors (
state => state.field) when reading in components - Define actions inside the store via
set() - Compose multiple stores through custom hooks
- Use
.getState()for imperative access outside React
❌ DON'T
- Don't use providers or context wrappers — Zustand doesn't need them
- Don't destructure the entire store in components
- Don't put service factory logic inside store definitions
- Don't default-export stores (named exports only)
- Don't nest stores or create store hierarchies
Signals
- GitHub stars
- 40
- Forks
- 1
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
zustand-state-management- Source
- github.com/cliqrelay/cliqrelay