zustand-state

Zustand State Management

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "zustand-state" with this command: npx skills add toilahuongg/shopify-agents-kit/toilahuongg-shopify-agents-kit-zustand-state

Zustand State Management

Zustand is a lightweight, fast state management solution perfect for Shopify Remix apps. It's simpler than Redux, more flexible than Context, and works seamlessly with TypeScript.

Why Zustand for Shopify Apps?

  • Minimal boilerplate: No providers, no reducers, no actions

  • TypeScript-first: Full type inference out of the box

  • Small bundle: ~1KB gzipped

  • Remix-friendly: Works with SSR without hydration issues

  • Selective re-renders: Components only re-render when their selected state changes

Installation

npm install zustand

  1. Basic Store Pattern

Simple Store

// app/stores/app-store.ts import { create } from 'zustand';

interface AppState { // State isLoading: boolean; selectedProductIds: string[];

// Actions setLoading: (loading: boolean) => void; selectProduct: (id: string) => void; deselectProduct: (id: string) => void; clearSelection: () => void; }

export const useAppStore = create<AppState>((set) => ({ // Initial state isLoading: false, selectedProductIds: [],

// Actions setLoading: (loading) => set({ isLoading: loading }),

selectProduct: (id) => set((state) => ({ selectedProductIds: [...state.selectedProductIds, id] })),

deselectProduct: (id) => set((state) => ({ selectedProductIds: state.selectedProductIds.filter(pid => pid !== id) })),

clearSelection: () => set({ selectedProductIds: [] }), }));

Usage in Components

// app/components/ProductList.tsx import { useAppStore } from '~/stores/app-store';

export function ProductList() { // Select only what you need (prevents unnecessary re-renders) const selectedIds = useAppStore((state) => state.selectedProductIds); const selectProduct = useAppStore((state) => state.selectProduct);

return ( <ResourceList items={products} renderItem={(product) => ( <ResourceItem id={product.id} selected={selectedIds.includes(product.id)} onClick={() => selectProduct(product.id)} /> )} /> ); }

  1. TypeScript Patterns

Slice Pattern (Recommended for Large Apps)

// app/stores/slices/product-slice.ts import { StateCreator } from 'zustand';

export interface ProductSlice { products: Product[]; selectedProductId: string | null; setProducts: (products: Product[]) => void; selectProduct: (id: string | null) => void; }

export const createProductSlice: StateCreator<ProductSlice> = (set) => ({ products: [], selectedProductId: null, setProducts: (products) => set({ products }), selectProduct: (id) => set({ selectedProductId: id }), });

// app/stores/slices/ui-slice.ts import { StateCreator } from 'zustand';

export interface UISlice { isSidebarOpen: boolean; activeModal: string | null; toggleSidebar: () => void; openModal: (modalId: string) => void; closeModal: () => void; }

export const createUISlice: StateCreator<UISlice> = (set) => ({ isSidebarOpen: true, activeModal: null, toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), openModal: (modalId) => set({ activeModal: modalId }), closeModal: () => set({ activeModal: null }), });

// app/stores/index.ts import { create } from 'zustand'; import { ProductSlice, createProductSlice } from './slices/product-slice'; import { UISlice, createUISlice } from './slices/ui-slice';

type StoreState = ProductSlice & UISlice;

export const useStore = create<StoreState>()((...args) => ({ ...createProductSlice(...args), ...createUISlice(...args), }));

Typed Selectors (Performance Optimization)

// app/stores/selectors.ts import { useStore } from './index'; import { useShallow } from 'zustand/react/shallow';

// Single value selector export const useSelectedProductId = () => useStore((state) => state.selectedProductId);

// Multiple values with shallow compare (prevents unnecessary re-renders) export const useProductState = () => useStore( useShallow((state) => ({ products: state.products, selectedId: state.selectedProductId, })) );

// Derived state export const useSelectedProduct = () => useStore((state) => state.products.find(p => p.id === state.selectedProductId) );

  1. Persistence (LocalStorage/SessionStorage)

// app/stores/settings-store.ts import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsState { theme: 'light' | 'dark'; itemsPerPage: number; setTheme: (theme: 'light' | 'dark') => void; setItemsPerPage: (count: number) => void; }

export const useSettingsStore = create<SettingsState>()( persist( (set) => ({ theme: 'light', itemsPerPage: 25, setTheme: (theme) => set({ theme }), setItemsPerPage: (count) => set({ itemsPerPage: count }), }), { name: 'app-settings', // localStorage key storage: createJSONStorage(() => localStorage), // Only persist specific fields partialize: (state) => ({ theme: state.theme, itemsPerPage: state.itemsPerPage }), } ) );

SSR-Safe Persistence (Important for Remix)

// app/stores/persisted-store.ts import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware';

// Prevent hydration mismatch const storage = createJSONStorage(() => { if (typeof window === 'undefined') { return { getItem: () => null, setItem: () => {}, removeItem: () => {}, }; } return localStorage; });

export const usePersistedStore = create<State>()( persist( (set) => ({ // ... state and actions }), { name: 'my-store', storage, skipHydration: true, // Manual hydration control } ) );

// In root.tsx or layout useEffect(() => { usePersistedStore.persist.rehydrate(); }, []);

  1. Shopify-Specific Patterns

Shop Context Store

// app/stores/shop-store.ts import { create } from 'zustand';

interface ShopInfo { id: string; name: string; domain: string; plan: string; currency: string; }

interface ShopState { shop: ShopInfo | null; isAuthenticated: boolean; setShop: (shop: ShopInfo) => void; clearShop: () => void; }

export const useShopStore = create<ShopState>((set) => ({ shop: null, isAuthenticated: false, setShop: (shop) => set({ shop, isAuthenticated: true }), clearShop: () => set({ shop: null, isAuthenticated: false }), }));

Bulk Operations Store

// app/stores/bulk-operations-store.ts import { create } from 'zustand';

type OperationStatus = 'idle' | 'running' | 'completed' | 'failed';

interface BulkOperation { id: string; type: 'product-update' | 'inventory-sync' | 'price-change'; status: OperationStatus; progress: number; totalItems: number; processedItems: number; errors: string[]; }

interface BulkOperationsState { operations: Map<string, BulkOperation>; activeOperationId: string | null;

startOperation: (id: string, type: BulkOperation['type'], totalItems: number) => void; updateProgress: (id: string, processedItems: number) => void; completeOperation: (id: string) => void; failOperation: (id: string, error: string) => void; clearOperation: (id: string) => void; }

export const useBulkOperationsStore = create<BulkOperationsState>((set, get) => ({ operations: new Map(), activeOperationId: null,

startOperation: (id, type, totalItems) => set((state) => { const operations = new Map(state.operations); operations.set(id, { id, type, status: 'running', progress: 0, totalItems, processedItems: 0, errors: [], }); return { operations, activeOperationId: id }; }),

updateProgress: (id, processedItems) => set((state) => { const operations = new Map(state.operations); const op = operations.get(id); if (op) { operations.set(id, { ...op, processedItems, progress: Math.round((processedItems / op.totalItems) * 100), }); } return { operations }; }),

completeOperation: (id) => set((state) => { const operations = new Map(state.operations); const op = operations.get(id); if (op) { operations.set(id, { ...op, status: 'completed', progress: 100 }); } return { operations, activeOperationId: state.activeOperationId === id ? null : state.activeOperationId }; }),

failOperation: (id, error) => set((state) => { const operations = new Map(state.operations); const op = operations.get(id); if (op) { operations.set(id, { ...op, status: 'failed', errors: [...op.errors, error] }); } return { operations }; }),

clearOperation: (id) => set((state) => { const operations = new Map(state.operations); operations.delete(id); return { operations }; }), }));

Resource Picker State

// app/stores/resource-picker-store.ts import { create } from 'zustand';

interface SelectedResource { id: string; title: string; handle?: string; images?: { originalSrc: string }[]; }

interface ResourcePickerState { isOpen: boolean; resourceType: 'Product' | 'Collection' | 'Customer' | null; selectedResources: SelectedResource[]; maxSelectable: number;

openPicker: (type: ResourcePickerState['resourceType'], max?: number) => void; closePicker: () => void; setSelectedResources: (resources: SelectedResource[]) => void; clearSelection: () => void; }

export const useResourcePickerStore = create<ResourcePickerState>((set) => ({ isOpen: false, resourceType: null, selectedResources: [], maxSelectable: 1,

openPicker: (type, max = 1) => set({ isOpen: true, resourceType: type, maxSelectable: max }),

closePicker: () => set({ isOpen: false }),

setSelectedResources: (resources) => set({ selectedResources: resources, isOpen: false, }),

clearSelection: () => set({ selectedResources: [] }), }));

  1. Middleware & DevTools

DevTools Integration

import { create } from 'zustand'; import { devtools } from 'zustand/middleware';

export const useStore = create<State>()( devtools( (set) => ({ // ... state and actions }), { name: 'ShopifyAppStore', enabled: process.env.NODE_ENV === 'development', } ) );

Immer Middleware (Immutable Updates)

import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer';

interface State { products: Product[]; updateProduct: (id: string, updates: Partial<Product>) => void; }

export const useStore = create<State>()( immer((set) => ({ products: [], updateProduct: (id, updates) => set((state) => { const product = state.products.find(p => p.id === id); if (product) { Object.assign(product, updates); // Direct mutation is OK with immer } }), })) );

Combined Middleware

import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer';

export const useStore = create<State>()( devtools( persist( immer((set) => ({ // ... state and actions })), { name: 'app-store' } ), { name: 'ShopifyAppStore' } ) );

  1. Testing Zustand Stores

// app/stores/tests/app-store.test.ts import { act, renderHook } from '@testing-library/react'; import { useAppStore } from '../app-store';

describe('useAppStore', () => { beforeEach(() => { // Reset store before each test useAppStore.setState({ isLoading: false, selectedProductIds: [], }); });

it('should select a product', () => { const { result } = renderHook(() => useAppStore());

act(() => {
  result.current.selectProduct('gid://shopify/Product/123');
});

expect(result.current.selectedProductIds).toContain('gid://shopify/Product/123');

});

it('should clear selection', () => { useAppStore.setState({ selectedProductIds: ['1', '2', '3'] });

const { result } = renderHook(() => useAppStore());

act(() => {
  result.current.clearSelection();
});

expect(result.current.selectedProductIds).toHaveLength(0);

}); });

Anti-Patterns to Avoid

DON'T: Store server data in Zustand

// BAD - Server data should come from loaders const useStore = create((set) => ({ products: [], // Server data fetchProducts: async () => { const res = await fetch('/api/products'); set({ products: await res.json() }); }, }));

// GOOD - Use Remix loaders for server data // Use Zustand only for UI state export async function loader() { return json({ products: await getProducts() }); }

DON'T: Create stores inside components

// BAD - Creates new store on every render function MyComponent() { const useLocalStore = create((set) => ({ count: 0 })); // ... }

// GOOD - Define stores outside components const useLocalStore = create((set) => ({ count: 0 }));

function MyComponent() { const count = useLocalStore((s) => s.count); // ... }

DON'T: Select entire state object

// BAD - Re-renders on ANY state change const state = useStore();

// GOOD - Select only what you need const count = useStore((s) => s.count); const { count, increment } = useStore(useShallow((s) => ({ count: s.count, increment: s.increment })));

Best Practices Summary

  • Keep stores small and focused - One store per domain/feature

  • Use selectors - Always select minimal state needed

  • Server data in loaders - Zustand for UI state only

  • TypeScript everything - Full type safety for stores

  • Test stores in isolation - Use setState for setup

  • DevTools in development - Easier debugging

  • SSR-safe persistence - Handle hydration properly

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

shopify-polaris-icons

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-polaris-viz

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-api

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

shopify-extensions

No summary provided by upstream source.

Repository SourceNeeds Review