Zustand Integration
QuickModel's $qCopy() method is a natural fit for Zustand stores — it returns a new immutable instance, keeping state updates predictable and avoiding the need for Immer.
Key Patterns
| Pattern | QuickModel API |
|---|---|
| Immutable state update | item.$qCopy(patch) → new instance |
| Normalized Map store | createMany() → Map<id, instance> |
| Persist middleware | $qSerialize() / new Dto(stored) |
| Reactive computed values | @QComputed — recalculates on every read |
| Bulk initial load | Dto.createMany(apiData) |
Installation
npm install quickmodel zustandModel Setup
import { QModel, Quick, QField, QComputed } from 'quickmodel';
interface IUser {
id: string;
name: string;
email: string;
age: number;
plan: 'free' | 'pro';
}
@Quick(
{
id: 'string',
name: 'string',
email: 'string',
age: 'number',
plan: 'string',
},
{ unknownPropertyPolicy: 'strip', coercionStrategy: 'loose' }
)
class UserModel extends QModel<IUser> {
declare id: string;
declare name: string;
declare email: string;
declare age: number;
declare plan: string;
@QComputed()
get fullLabel(): string {
return `${this.name} <${this.email}> [${this.plan}]`;
}
}Basic Store — $qCopy() as Immutable Updater
merge(patch) returns a new instance with patched fields. The original is never mutated, and @QComputed values recalculate automatically on the new instance.
import { create } from 'zustand';
interface IUserStore {
user: UserModel | null;
setUser: (user: UserModel) => void;
updateUser: (patch: Partial<IUser>) => void;
}
const useUserStore = create<IUserStore>((set, get) => ({
user: null,
setUser: (user) => set({ user }),
updateUser: (patch) => {
const current = get().user;
if (!current) return;
set({ user: current.$qCopy(patch) }); // immutable update — no Immer needed
},
}));
// Usage
const { user, updateUser } = useUserStore();
updateUser({ plan: 'pro' });
console.log(user?.fullLabel); // @QComputed recalculatedNormalized Map Store
Use createMany() to bulk-load from an API and Map for O(1) access by ID:
interface ICartStore {
items: Map<string, CartItemModel>;
addItem: (item: ICartItem) => void;
updateQty: (productId: string, qty: number) => void;
removeItem: (productId: string) => void;
total: number;
}
const useCartStore = create<ICartStore>((set, get) => ({
items: new Map(),
addItem: (item) =>
set((state) => {
const dto = new CartItemModel(item);
const next = new Map(state.items);
next.set(dto.productId, dto);
return { items: next };
}),
updateQty: (productId, qty) =>
set((state) => {
const existing = state.items.get(productId);
if (!existing) return state;
const next = new Map(state.items);
next.set(productId, existing.$qCopy({ qty })); // immutable merge
return { items: next };
}),
removeItem: (productId) =>
set((state) => {
const next = new Map(state.items);
next.delete(productId);
return { items: next };
}),
get total() {
return [...get().items.values()].reduce(
(sum, item) => sum + item.subtotal,
0
);
},
}));Persist Middleware
Serialize on write, rehydrate on read. Works with zustand/middleware persist:
import { persist } from 'zustand/middleware';
// Serialize for storage
function serializeState(user: UserModel): object {
return user.$qSerialize() as object;
}
// Rehydrate from storage
function deserializeState(data: object): UserModel {
return new UserModel(data);
}
const usePersistedUserStore = create<IUserStore>()(
persist(
(set, get) => ({
user: null,
setUser: (user) => set({ user }),
updateUser: (patch) => {
const current = get().user;
if (!current) return;
set({ user: current.$qCopy(patch) });
},
}),
{
name: 'user-store',
storage: {
getItem: (name) => {
const raw = localStorage.getItem(name);
if (!raw) return null;
const parsed = JSON.parse(raw);
// Rehydrate UserModel instances
if (parsed.state?.user) {
parsed.state.user = deserializeState(parsed.state.user);
}
return parsed;
},
setItem: (name, value) => {
const serializable = {
...value,
state: {
...value.state,
user: value.state.user
? serializeState(value.state.user)
: null,
},
};
localStorage.setItem(name, JSON.stringify(serializable));
},
removeItem: (name) => localStorage.removeItem(name),
},
}
)
);Bulk Loading from API
async function loadUsersIntoStore() {
const response = await fetch('/api/users');
const rawUsers: unknown[] = await response.json();
const { instances, errors } = UserModel.createMany(rawUsers);
if (errors.length > 0) {
console.warn('Some users failed validation:', errors);
}
// Build normalized Map
const userMap = new Map(instances.map((u) => [u.id, u]));
useUserListStore.setState({ users: userMap });
}$qCopy() vs Immer
Immer requires a produce() wrapper to enable structural sharing. With QuickModel, $qCopy() is already immutable and returns a typed instance with recalculated @QComputed fields:
// ❌ With Immer
set(
produce((state) => {
state.user.plan = 'pro';
})
);
// ✅ With QuickModel $qCopy()
const current = get().user;
set({ user: current.$qCopy({ plan: 'pro' }) });
// @QComputed values recalculate automatically — no stale references