Skip to content

React Integration ​

QuickModel works with React using plain TypeScript classes for form validation and QModel subclasses for data management. No React-specific APIs are required — just TypeScript.

Key Separation ​

Use caseApproach
Controlled form validationPlain TS class + @QRule + qCheckRules()
Server Actions / API coercionQModel subclass + @Quick()
Global state (Zustand / useReducer)QModel + $qCopy() (immutable updates)
Custom hooksWrap QModel in a useQModel hook

Installation ​

bash
npm install quickmodel

Enable decorators in tsconfig.json:

json
{
	"compilerOptions": {
		"experimentalDecorators": true,
		"emitDecoratorMetadata": true
	}
}

Controlled Form Validation (useState) ​

typescript
import { QRule, QField } from 'quickmodel';
import { qCheckRules } from 'quickmodel/forms';

class LoginForm {
	@QField({ label: 'Email', widget: 'email', required: true })
	@QRule(
		(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
		'Invalid email address'
	)
	email = '';

	@QField({ label: 'Password', widget: 'password', required: true })
	@QRule(
		(v: string) => v.length >= 8,
		'Password must be at least 8 characters'
	)
	@QRule((v: string) => /[A-Z]/.test(v), 'Password needs an uppercase letter')
	password = '';
}
tsx
// LoginPage.tsx
function LoginPage() {
	const [form] = useState(() => new LoginForm());
	const [errors, setErrors] = useState<Record<string, string>>({});

	const handleSubmit = (e: FormEvent) => {
		e.preventDefault();
		const result = qCheckRules(form);
		if (!result.valid) {
			const map: Record<string, string> = {};
			result.errors.forEach((err) => {
				map[err.field] = err.message;
			});
			setErrors(map);
			return;
		}
		// proceed with form.email, form.password
	};

	return (
		<form onSubmit={handleSubmit}>
			<input
				value={form.email}
				onChange={(e) => {
					form.email = e.target.value;
				}}
				aria-invalid={!!errors.email}
			/>
			{errors.email && <span role="alert">{errors.email}</span>}
			{/* ... */}
		</form>
	);
}

Two available patterns

  • Plain class (above): @QRule + @QField only — no QModel inheritance required.
  • With QModel + @Quick (below): also unlocks $qCopy(), $qSerialize(), $qCheckIntegrity(), and immutable updates.

With QModel + @Quick ​

typescript
// models/login-form.ts
import { QModel, Quick, QField, QRule } from 'quickmodel';

@Quick({ email: 'string', password: 'string' })
class LoginForm extends QModel<ILoginForm> {
	@QField({ label: 'Email', widget: 'email', required: true })
	@QRule(
		(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
		'Invalid email address'
	)
	declare email: string;

	@QField({ label: 'Password', widget: 'password', required: true })
	@QRule(
		(v: string) => v.length >= 8,
		'Password must be at least 8 characters'
	)
	@QRule((v: string) => /[A-Z]/.test(v), 'Password needs an uppercase letter')
	declare password: string;
}
tsx
// LoginPage.tsx
const [form, setForm] = useState(() => new LoginForm({}));

const handleChange = (field: keyof ILoginForm, value: string) => {
	// $qCopy() is IMMUTABLE — captures the new instance
	setForm((prev) => prev.$qCopy({ [field]: value }) as LoginForm);
};

const handleSubmit = (e: FormEvent) => {
	e.preventDefault();
	const { valid, errors: ruleErrors } = form.$qCheckRules();
	if (!valid) {
		const map: Record<string, string> = {};
		ruleErrors.forEach((err) => {
			map[err.field] = err.message;
		});
		setErrors(map);
		return;
	}
	// proceed with form.email, form.password
};

React Hook Form — Custom Resolver ​

typescript
import { qCheckRules } from 'quickmodel/forms';
import type { Resolver } from 'react-hook-form';

export function qModelResolver<TForm extends object>(
	FormClass: new () => TForm
): Resolver<TForm> {
	return (values) => {
		const instance = Object.assign(new FormClass(), values);
		const result = qCheckRules(instance);
		if (result.valid) return { values, errors: {} };
		const errors = result.errors.reduce<
			Record<string, { message: string }>
		>((acc, e) => {
			if (!acc[e.field]) acc[e.field] = { message: e.message };
			return acc;
		}, {});
		return { values: {}, errors };
	};
}
tsx
// usage with react-hook-form
const { register, handleSubmit, formState } = useForm({
	resolver: qModelResolver(ProductForm),
});

Typed variant with QModel ​

If your form class extends QModel, you can enforce a stricter type contract and call instance.$qCheckRules() directly:

typescript
// hooks/useQModelResolver.ts
import type { Resolver } from 'react-hook-form';
import { QModel } from 'quickmodel';

export function createQModelResolver<T extends QModel<object>>(
	FormClass: new (data?: object) => T
): Resolver<T> {
	return (values) => {
		const instance = new FormClass(values);
		const { valid, errors } = instance.$qCheckRules();
		if (valid) return { values, errors: {} };
		return {
			values: {},
			errors: Object.fromEntries(
				errors.map((e) => [
					e.field,
					{ type: 'validation', message: e.message },
				])
			),
		};
	};
}
tsx
// ProductForm must extend QModel
const resolver = createQModelResolver(ProductForm);

Which one should I use?

GoalFunction
Validate with @QRule only, no inheritanceqModelResolver — accepts any class
Full QModel with coercion and serializationcreateQModelResolver — strong typing

Next.js Server Actions ​

Coerce and sanitize incoming FormData on the server:

typescript
// app/actions.ts
'use server';
import { QModel, Quick } from 'quickmodel';

@Quick(
	{
		productId: 'string',
		quantity: 'number',
		unitPrice: 'number',
		orderedAt: Date,
	},
	{ unknownPropertyPolicy: 'strip', coercionStrategy: 'loose' }
)
class OrderItemDto extends QModel<IOrderItem> {
	declare productId: string;
	declare quantity: number; // coerced from string FormData
	declare unitPrice: number; // coerced from string FormData
	declare orderedAt: Date;

	@QComputed()
	get totalPrice(): number {
		return this.quantity * this.unitPrice;
	}
}

export async function createOrder(formData: FormData) {
	const raw = Object.fromEntries(formData);
	const dto = new OrderItemDto(raw); // auto-coerces strings to numbers
	// dto.totalPrice is available as a @QComputed field
	return dto.$qSerialize();
}

coercionStrategy: 'loose'

FormData values are always strings. Add coercionStrategy: 'loose' to automatically convert '3' → 3, 'true' → true, etc.

Zustand Store Pattern ​

typescript
import { create } from 'zustand';
import { QModel, Quick, QComputed } from 'quickmodel';

@Quick(
	{ sku: 'string', name: 'string', qty: 'number', price: 'number' },
	{ unknownPropertyPolicy: 'strip' }
)
class CartItem extends QModel<ICartItem> {
	declare sku: string;
	declare name: string;
	declare qty: number;
	declare price: number;

	@QComputed()
	get subtotal(): number {
		return this.qty * this.price;
	}
}

interface ICartStore {
	items: Map<string, CartItem>;
	addItem: (data: object) => void;
	updateQty: (sku: string, qty: number) => void;
	total: () => number;
}

const useCartStore = create<ICartStore>((set, get) => ({
	items: new Map(),

	addItem: (data) =>
		set((state) => {
			const item = new CartItem(data);
			const items = new Map(state.items);
			items.set(item.sku, item);
			return { items };
		}),

	updateQty: (sku, qty) =>
		set((state) => {
			const current = state.items.get(sku);
			if (!current) return state;
			const items = new Map(state.items);
			// $qCopy() is IMMUTABLE — always capture the returned new instance
			items.set(sku, current.$qCopy({ qty }));
			return { items };
		}),

	total: () =>
		[...get().items.values()].reduce(
			(sum, item) => sum + item.qty * item.price,
			0
		),
}));

Custom Hook — useQModel ​

typescript
import { useReducer, useCallback } from 'react';
import { QModel } from 'quickmodel';

export function useQModel<T extends object, M extends QModel<T>>(initial: M) {
	const [model, dispatch] = useReducer((_prev: M, next: M) => next, initial);

	const update = useCallback(
		(patch: Partial<T>) => dispatch(model.$qCopy(patch) as M), // as M is safe: M extends QModel<T>
		[model]
	);

	return { model, update, snapshot: model.$qSerialize() };
}
tsx
// usage
const { model, update, snapshot } = useQModel(
	new UserProfile({
		id: 'u1',
		displayName: 'Alice',
		bio: 'Dev',
		avatarUrl: '',
	})
);

// update via immutable merge
update({ bio: 'Senior Dev' });

Async Validation ​

typescript
import { qCheckRulesAsync } from 'quickmodel/forms';

// in a useEffect or server action
const result = await qCheckRulesAsync(form, { mode: 'parallel' });
if (!result.valid) {
	setErrors(result.errors);
}

createMany() — Bulk Data Loading ​

typescript
// In Next.js page or data fetching
const raw = await fetch('/api/products').then((r) => r.json());
const { instances, errors } = ProductModel.createMany(raw);
// All items are coerced and type-safe
const serialized = instances.map((p) => p.$qSerialize());