$state() for Svelte 5Deep reactive proxy with validation, snapshot/undo, and side effects โ built for complex, real-world applications.
๐ฎ Live Demo ยท Installation ยท Features ยท Examples
Svelte 5's $state() is fantastic for simple use cases. A login form? Easy. A settings toggle? Trivial.
But what about real enterprise applications?
// โ A simple user/password form is NOT your problem
const loginForm = $state({ username: '', password: '' });
// โ
THIS is your problem โ a complex ERP customer page
const customer = $state({
name: 'Acme Corp',
taxId: 'US-12345678',
creditLimit: 50000,
addresses: [
{ type: 'billing', street: '123 Main St', city: 'New York', zip: '10001' },
{ type: 'shipping', street: '456 Oak Ave', city: 'Boston', zip: '02101' }
],
contacts: [
{ name: 'John Doe', email: '[email protected]', phone: '555-1234', isPrimary: true },
{ name: 'Jane Smith', email: '[email protected]', phone: '555-5678', isPrimary: false }
],
billing: {
paymentTerms: 'NET30',
currency: 'USD',
bankAccount: { iban: 'US12345678901234567890', swift: 'BOFA1234' }
}
});
With native Svelte 5, you're missing:
svstate wraps your state in a deep reactive proxy that:
customer.billing.bankAccount.iban)import { createSvState, stringValidator, numberValidator } from 'svstate';
const { data, state, rollback, rollbackTo, reset, execute } = createSvState(customer, {
validator: (source) => ({
/* validation that mirrors your structure */
}),
effect: ({ snapshot, property, currentValue, oldValue }) => {
console.log(`${property} changed from ${oldValue} to ${currentValue}`);
snapshot(`Changed ${property}`); // Create undo point
},
action: async () => {
/* Save to API */
}
});
// Deep binding just works!
data.billing.bankAccount.iban = 'NEW-IBAN'; // โ
Detected, validated, snapshot created
npm install svstate
Requirements: Node.js โฅ20, Svelte 5
Note: This package is distributed as ESM (ES Modules) only.
Validation in svstate mirrors your data structure exactly. When you have nested objects, your validation errors have the same shape. No more flattening, no more path strings.
Built-in fluent validators handle common patterns with chainable methods:
import { createSvState, stringValidator, numberValidator, dateValidator } from 'svstate';
const {
data,
state: { errors, hasErrors }
} = createSvState(
{
email: '',
age: 0,
birthDate: new Date(),
tags: []
},
{
validator: (source) => ({
// Fluent API: chain validations, get first error
email: stringValidator(source.email)
.prepare('trim') // preprocessing applied before validation
.required()
.email()
.maxLength(100)
.getError(),
age: numberValidator(source.age).required().integer().between(18, 120).getError(),
birthDate: dateValidator(source.birthDate).required().past().minAge(18).getError(),
tags: arrayValidator(source.tags).minLength(1).maxLength(10).unique().getError()
})
}
);
// In your template:
// $errors?.email โ "Required" | "Invalid email format" | ""
// $hasErrors โ true/false
Key features:
.prepare(): 'trim', 'normalize', 'upper', 'lower', 'localeUpper', 'localeLower'getError() returns the first failurerequiredIf(condition) on all validatorsFor server-side validation (checking username availability, email verification, etc.), svstate supports async validators that run after sync validation passes:
import { createSvState, stringValidator, type AsyncValidator } from 'svstate';
type UserForm = { username: string; email: string };
const asyncValidators: AsyncValidator<UserForm> = {
username: async (value, source, signal) => {
// Skip if empty (let sync validation handle required)
if (!value) return '';
const response = await fetch(`/api/check-username?name=${value}`, { signal });
const { available } = await response.json();
return available ? '' : 'Username already taken';
},
email: async (value, source, signal) => {
if (!value) return '';
const response = await fetch(`/api/check-email?email=${value}`, { signal });
const { valid } = await response.json();
return valid ? '' : 'Email not deliverable';
}
};
const {
data,
state: { errors, asyncErrors, asyncValidating, hasAsyncErrors, hasCombinedErrors }
} = createSvState(
{ username: '', email: '' },
{
validator: (source) => ({
username: stringValidator(source.username).required().minLength(3).getError(),
email: stringValidator(source.email).required().email().getError()
}),
asyncValidator: asyncValidators
},
{ debounceAsyncValidation: 500 }
);
// In template:
// {#if $asyncValidating.includes('username')}Checking...{/if}
// {#if $asyncErrors.username}{$asyncErrors.username}{/if}
// <button disabled={$hasCombinedErrors}>Submit</button>
Key features:
AbortSignal for automatic cancellationasyncValidating shows which paths are currently checkingmaxConcurrentAsyncValidations limits parallel requests (default: 4)JavaScript objects don't have property change events. svstate fixes this. The effect callback fires whenever any property changes, giving you full context:
const { data } = createSvState(formData, {
effect: ({ target, property, currentValue, oldValue, snapshot }) => {
// 'property' is the dot-notation path: "address.city", "contacts.0.email"
console.log(`${property}: ${oldValue} โ ${currentValue}`);
// Create undo point on significant changes
if (property.startsWith('billing')) {
snapshot(`Modified billing: ${property}`);
}
// Trigger side effects
if (property === 'country') {
loadTaxRates(currentValue);
}
}
});
Use cases:
Each svstate instance has one action โ typically for submitting data to your backend, REST API, or cloud database (Supabase, Firebase, etc.). The actionInProgress store lets you show loading spinners and disable UI while waiting for the server response. This is why async support is essential.
const { data, execute, state: { actionInProgress, actionError } } = createSvState(
formData,
{
validator: (source) => ({ /* ... */ }),
action: async (params) => {
// Submit to your backend, Supabase, Firebase, etc.
const response = await fetch('/api/customers', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Save failed');
},
actionCompleted: (error) => {
if (!error) showToast('Saved successfully!');
}
}
);
// Show loading state while waiting for server
<button onclick={() => execute()} disabled={$hasErrors || $actionInProgress}>
{$actionInProgress ? 'Saving...' : 'Save'}
</button>
Action parameters โ When you need different submit behaviors from multiple places (e.g., "Save Draft" vs "Publish", or "Save" vs "Save & Close"), pass parameters to execute():
const { data, execute } = createSvState(articleData, {
action: async (params?: { draft?: boolean; redirect?: string }) => {
await supabase.from('articles').upsert({
...data,
status: params?.draft ? 'draft' : 'published',
published_at: params?.draft ? null : new Date()
});
if (params?.redirect) goto(params.redirect);
}
});
// Multiple submit buttons with different behaviors
<button onclick={() => execute({ draft: true })}>
Save Draft
</button>
<button onclick={() => execute({ draft: false, redirect: '/articles' })}>
Publish & Go Back
</button>
<button onclick={() => execute()}>
Publish
</button>
Key features:
actionInProgress โ show spinners, disable inputs while waitingactionError store captures failuresComplex forms need undo. svstate provides a snapshot system that captures state at meaningful moments:
const {
data,
rollback,
rollbackTo,
reset,
state: { snapshots }
} = createSvState(formData, {
effect: ({ snapshot, property }) => {
// Create snapshot on each change
// Same title = replaces previous (debouncing)
snapshot(`Changed ${property}`);
// Use snapshot(title, false) to always create new
}
});
// Undo last change
rollback();
// Undo 3 changes
rollback(3);
// Roll back to a named snapshot (returns true if found)
rollbackTo('Changed email');
// Roll back to initial state by name
rollbackTo('Initial');
// Reset to initial state
reset();
// Show history
$snapshots.forEach((s, i) => console.log(`${i}: ${s.title}`));
Key features:
snapshot(title, replace?) โ create undo pointsrollback(steps) โ undo N changesrollbackTo(title) โ jump to a named snapshotreset() โ return to initial statesnapshots store โ access full historymaxSnapshots option โ LRU trimming to prevent unbounded growthCustomize svstate behavior with options:
const { data } = createSvState(formData, actuators, {
// Reset isDirty after successful action (default: true)
resetDirtyOnAction: true,
// Debounce sync validation in ms (default: 0 = microtask)
debounceValidation: 300,
// Allow concurrent action executions (default: false)
allowConcurrentActions: false,
// Keep actionError until next action (default: false)
persistActionError: false,
// Debounce async validation in ms (default: 300)
debounceAsyncValidation: 500,
// Run async validators on state creation (default: false)
runAsyncValidationOnInit: false,
// Clear async error when property changes (default: true)
clearAsyncErrorsOnChange: true,
// Max concurrent async validators (default: 4)
maxConcurrentAsyncValidations: 4,
// Max snapshots to keep, 0 = unlimited (default: 50)
maxSnapshots: 50
});
| Option | Default | Description |
|---|---|---|
resetDirtyOnAction |
true |
Clear dirty flag after successful action |
debounceValidation |
0 |
Delay sync validation (0 = next microtask) |
allowConcurrentActions |
false |
Block execute() while action runs |
persistActionError |
false |
Clear error on next change or action |
debounceAsyncValidation |
300 |
Delay async validation in ms |
runAsyncValidationOnInit |
false |
Run async validators on creation |
clearAsyncErrorsOnChange |
true |
Clear async error when property changes |
maxConcurrentAsyncValidations |
4 |
Max concurrent async validators |
maxSnapshots |
50 |
Max snapshots to keep (0 = unlimited) |
State objects can include methods that operate on this. Methods are preserved through snapshots and undo operations, making it easy to encapsulate computed values and formatting logic:
import { createSvState, numberValidator } from 'svstate';
// Define state with methods
type InvoiceData = {
unitPrice: number;
quantity: number;
subtotal: number;
tax: number;
total: number;
calculateTotals: (taxRate?: number) => void;
formatCurrency: (value: number) => string;
};
const createInvoice = (): InvoiceData => ({
unitPrice: 0,
quantity: 1,
subtotal: 0,
tax: 0,
total: 0,
calculateTotals(taxRate = 0.08) {
this.subtotal = this.unitPrice * this.quantity;
this.tax = this.subtotal * taxRate;
this.total = this.subtotal + this.tax;
},
formatCurrency(value: number) {
return `$${value.toFixed(2)}`;
}
});
const {
data,
state: { errors }
} = createSvState(createInvoice(), {
validator: (source) => ({
unitPrice: numberValidator(source.unitPrice).required().positive().getError(),
quantity: numberValidator(source.quantity).required().integer().min(1).getError()
}),
effect: ({ property }) => {
// Call method directly on state when inputs change
if (property === 'unitPrice' || property === 'quantity') {
data.calculateTotals();
}
}
});
// In template: use methods for formatting
// {data.formatCurrency(data.subtotal)} โ "$99.00"
// {data.formatCurrency(data.total)} โ "$106.92"
Key features:
this properties (triggers validation/effects)rollback() and reset()A complex customer management form with 3-level nesting, validation, undo, and API save:
<script lang="ts">
import { createSvState, stringValidator, numberValidator, arrayValidator } from 'svstate';
// ๐ Complex nested data structure
const initialCustomer = {
name: '',
taxId: '',
creditLimit: 0,
address: {
street: '',
city: '',
zip: '',
country: ''
},
contacts: [
{ name: '', email: '', phone: '', isPrimary: true }
],
billing: {
paymentTerms: 'NET30',
currency: 'USD',
bankAccount: {
iban: '',
swift: ''
}
}
};
// ๐ Create supercharged state
const {
data, // Deep reactive proxy
execute, // Trigger async action
rollback, // Undo changes
reset, // Reset to initial
state: {
errors, // Validation errors (same structure as data)
hasErrors, // Quick boolean check
isDirty, // Has anything changed?
actionInProgress, // Is action running?
actionError, // Last action error
snapshots // Undo history
}
} = createSvState(initialCustomer, {
// โ
Validator mirrors data structure exactly
validator: (source) => ({
name: stringValidator(source.name)
.prepare('trim')
.required()
.minLength(2)
.maxLength(100)
.getError(),
taxId: stringValidator(source.taxId)
.prepare('trim', 'upper')
.required()
.regexp(/^[A-Z]{2}-\d{8}$/, 'Format: XX-12345678')
.getError(),
creditLimit: numberValidator(source.creditLimit)
.required()
.min(0)
.max(1_000_000)
.getError(),
// ๐ Nested address validation
address: {
street: stringValidator(source.address.street)
.prepare('trim')
.required()
.minLength(5)
.getError(),
city: stringValidator(source.address.city)
.prepare('trim')
.required()
.getError(),
zip: stringValidator(source.address.zip)
.prepare('trim')
.required()
.minLength(5)
.getError(),
country: stringValidator(source.address.country)
.required()
.in(['US', 'CA', 'UK', 'DE', 'FR'])
.getError()
},
// ๐ Array validation
contacts: arrayValidator(source.contacts)
.required()
.minLength(1)
.getError(),
// ๐ณ 3-level nested billing validation
billing: {
paymentTerms: stringValidator(source.billing.paymentTerms)
.required()
.in(['NET15', 'NET30', 'NET60', 'COD'])
.getError(),
currency: stringValidator(source.billing.currency)
.required()
.in(['USD', 'EUR', 'GBP'])
.getError(),
bankAccount: {
iban: stringValidator(source.billing.bankAccount.iban)
.prepare('trim', 'upper')
.required()
.minLength(15)
.maxLength(34)
.getError(),
swift: stringValidator(source.billing.bankAccount.swift)
.prepare('trim', 'upper')
.required()
.minLength(8)
.maxLength(11)
.getError()
}
}
}),
// โก Effect fires on every change
effect: ({ snapshot, property, currentValue, oldValue }) => {
// Create undo point with descriptive title
const fieldName = property.split('.').pop();
snapshot(`Changed ${fieldName}`);
// Log for debugging
console.log(`[svstate] ${property}: "${oldValue}" โ "${currentValue}"`);
},
// ๐ Async save action
action: async () => {
const response = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to save customer');
}
},
// โ
Called after action (success or failure)
actionCompleted: (error) => {
if (error) {
console.error('Save failed:', error);
} else {
console.log('Customer saved successfully!');
}
}
});
</script>
<!-- ๐ Template with deep bindings -->
<form onsubmit|preventDefault={() => execute()}>
<!-- Basic fields -->
<input bind:value={data.name} placeholder="Company Name" />
{#if $errors?.name}<span class="error">{$errors.name}</span>{/if}
<!-- 2-level nested: address.city -->
<input bind:value={data.address.city} placeholder="City" />
{#if $errors?.address?.city}<span class="error">{$errors.address.city}</span>{/if}
<!-- 3-level nested: billing.bankAccount.iban -->
<input bind:value={data.billing.bankAccount.iban} placeholder="IBAN" />
{#if $errors?.billing?.bankAccount?.iban}
<span class="error">{$errors.billing.bankAccount.iban}</span>
{/if}
<!-- Action buttons -->
<div class="actions">
<button type="submit" disabled={$hasErrors || $actionInProgress}>
{$actionInProgress ? 'Saving...' : 'Save Customer'}
</button>
<button type="button" onclick={() => rollback()} disabled={$snapshots.length <= 1}>
Undo ({$snapshots.length - 1})
</button>
<button type="button" onclick={reset} disabled={!$isDirty}>
Reset
</button>
</div>
{#if $actionError}
<div class="error-banner">{$actionError.message}</div>
{/if}
</form>
Managing arrays of items with validation at both array and item level:
<script lang="ts">
import { createSvState, stringValidator, numberValidator, arrayValidator } from 'svstate';
// ๐ฆ Product with inventory items
const initialProduct = {
sku: '',
name: '',
description: '',
price: 0,
inventory: [
{ warehouseId: 'WH-001', quantity: 0, reorderPoint: 10 }
],
tags: [] as string[]
};
const {
data,
rollback,
state: { errors, hasErrors, isDirty, snapshots }
} = createSvState(initialProduct, {
validator: (source) => ({
sku: stringValidator(source.sku)
.prepare('trim', 'upper')
.required()
.regexp(/^[A-Z]{3}-\d{4}$/, 'Format: ABC-1234')
.getError(),
name: stringValidator(source.name)
.prepare('trim')
.required()
.minLength(3)
.maxLength(100)
.getError(),
description: stringValidator(source.description)
.prepare('trim')
.maxLength(500)
.getError(),
price: numberValidator(source.price)
.required()
.positive()
.decimal(2) // Max 2 decimal places
.getError(),
// ๐ Validate the array itself
inventory: arrayValidator(source.inventory)
.required()
.minLength(1)
.getError(),
// ๐ท๏ธ Tags must be unique
tags: arrayValidator(source.tags)
.maxLength(10)
.unique()
.getError()
}),
effect: ({ snapshot, property, currentValue }) => {
// Create snapshots for significant changes
if (property === 'price') {
snapshot(`Price: $${currentValue}`);
} else if (property.startsWith('inventory')) {
snapshot(`Updated inventory`);
} else {
snapshot(`Changed ${property}`);
}
}
});
// ๐ง Array manipulation functions
function addWarehouse() {
data.inventory.push({
warehouseId: `WH-${String(data.inventory.length + 1).padStart(3, '0')}`,
quantity: 0,
reorderPoint: 10
});
}
function removeWarehouse(index: number) {
data.inventory.splice(index, 1);
}
function addTag(tag: string) {
if (tag && !data.tags.includes(tag)) {
data.tags.push(tag);
}
}
function removeTag(index: number) {
data.tags.splice(index, 1);
}
</script>
<!-- Product form -->
<div class="product-form">
<input bind:value={data.sku} placeholder="SKU (ABC-1234)" />
{#if $errors?.sku}<span class="error">{$errors.sku}</span>{/if}
<input bind:value={data.name} placeholder="Product Name" />
<input type="number" bind:value={data.price} step="0.01" placeholder="Price" />
<!-- ๐ฆ Inventory locations (array) -->
<section class="inventory">
<h3>Inventory Locations</h3>
{#if $errors?.inventory}
<span class="error">{$errors.inventory}</span>
{/if}
{#each data.inventory as item, index}
<div class="inventory-row">
<input bind:value={item.warehouseId} placeholder="Warehouse ID" />
<input type="number" bind:value={item.quantity} placeholder="Qty" />
<input type="number" bind:value={item.reorderPoint} placeholder="Reorder at" />
<button onclick={() => removeWarehouse(index)}>Remove</button>
</div>
{/each}
<button onclick={addWarehouse}>+ Add Warehouse</button>
</section>
<!-- ๐ท๏ธ Tags (simple array) -->
<section class="tags">
<h3>Tags</h3>
{#if $errors?.tags}<span class="error">{$errors.tags}</span>{/if}
<div class="tag-list">
{#each data.tags as tag, index}
<span class="tag">
{tag}
<button onclick={() => removeTag(index)}>ร</button>
</span>
{/each}
</div>
<input
placeholder="Add tag..."
onkeydown={(e) => {
if (e.key === 'Enter') {
addTag(e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
</section>
<!-- Status bar -->
<div class="status">
<span class:dirty={$isDirty}>{$isDirty ? 'Modified' : 'Saved'}</span>
<span>{$snapshots.length} snapshots</span>
<button onclick={() => rollback()} disabled={$snapshots.length <= 1}>
Undo
</button>
</div>
</div>
createSvState(init, actuators?, options?)Creates a supercharged state object.
Returns:
| Property | Type | Description |
|----------|------|-------------|
| data | T | Deep reactive proxy โ bind directly, methods preserved |
| execute(params?) | (P?) => Promise<void> | Run the configured action |
| rollback(steps?) | (n?: number) => void | Undo N changes (default: 1) |
| rollbackTo(title) | (title: string) => boolean | Roll back to last snapshot with matching title |
| reset() | () => void | Return to initial state |
| state.errors | Readable<V> | Sync validation errors store |
| state.hasErrors | Readable<boolean> | Has sync errors? |
| state.isDirty | Readable<boolean> | Has state changed? (derived from isDirtyByField) |
| state.isDirtyByField | Readable<DirtyFields> | Per-field dirty tracking (dot-notation paths) |
| state.actionInProgress | Readable<boolean> | Is action running? |
| state.actionError | Readable<Error> | Last action error |
| state.snapshots | Readable<Snapshot[]> | Undo history |
| state.asyncErrors | Readable<AsyncErrors> | Async validation errors (keyed by path) |
| state.hasAsyncErrors | Readable<boolean> | Has async errors? |
| state.asyncValidating | Readable<string[]> | Paths currently validating |
| state.hasCombinedErrors | Readable<boolean> | Has sync OR async errors? |
svstate ships with four fluent validator builders that cover the most common validation scenarios. Each validator uses a chainable API โ call validation methods in sequence and finish with getError() to retrieve the first error message (or an empty string if valid).
String validators support optional preprocessing ('trim', 'normalize', 'upper', 'lower') applied before validation. All validators return descriptive error messages that you can customize or use as-is.
| Validator | Methods |
|---|---|
stringValidator(input) |
prepare(...ops), required(), requiredIf(cond), minLength(n), maxLength(n), email(), regexp(re, msg?), in(arr), notIn(arr), startsWith(s), endsWith(s), contains(s), noSpace(), notBlank(), uppercase(), lowercase(), alphanumeric(), numeric(), slug(), identifier(), website(mode) |
numberValidator(input) |
required(), requiredIf(cond), min(n), max(n), between(min, max), integer(), positive(), negative(), nonNegative(), notZero(), multipleOf(n), step(n), decimal(places), percentage() |
arrayValidator(input) |
required(), requiredIf(cond), minLength(n), maxLength(n), ofLength(n), unique(), includes(item), includesAny(items), includesAll(items) |
dateValidator(input) |
required(), requiredIf(cond), before(date), after(date), between(start, end), past(), future(), weekday(), weekend(), minAge(years), maxAge(years) |
svstate exports TypeScript types to help you write type-safe external validator and effect functions. This is useful when you want to define these functions outside the createSvState call or reuse them across multiple state instances.
import type {
Validator,
EffectContext,
Snapshot,
SnapshotFunction,
SvStateOptions,
AsyncValidator,
AsyncValidatorFunction,
AsyncErrors,
DirtyFields
} from 'svstate';
| Type | Description |
|---|---|
Validator |
Nested object type for validation errors โ leaf values are error strings (empty = valid) |
EffectContext<T> |
Context object passed to effect callbacks: { snapshot, target, property, currentValue, oldValue } |
SnapshotFunction |
Type for the snapshot(title, replace?) function used in effects |
Snapshot<T> |
Shape of a snapshot entry: { title: string; data: T } |
SvStateOptions |
Configuration options type for createSvState |
AsyncValidator<T> |
Object mapping property paths to async validator functions |
AsyncValidatorFunction<T> |
Async function: (value, source, signal) => Promise<string> |
AsyncErrors |
Object mapping property paths to error strings |
DirtyFields |
Object mapping dot-notation property paths to boolean dirty status |
Example: External validator and effect functions
import { createSvState, stringValidator, type Validator, type EffectContext } from 'svstate';
// Define types for your data
type UserData = {
name: string;
email: string;
};
type UserErrors = {
name: string;
email: string;
};
// External validator function with proper typing
const validateUser = (source: UserData): UserErrors => ({
name: stringValidator(source.name, 'trim').required().minLength(2).getError(),
email: stringValidator(source.email, 'trim').required().email().getError()
});
// External effect function with proper typing
const userEffect = ({ snapshot, property, currentValue }: EffectContext<UserData>) => {
console.log(`${property} changed to ${currentValue}`);
snapshot(`Updated ${property}`);
};
// Use the external functions
const { data, state } = createSvState<UserData, UserErrors, object>(
{ name: '', email: '' },
{ validator: validateUser, effect: userEffect }
);
| Feature | Native Svelte 5 | svstate |
|---|---|---|
| Simple flat objects | โ Great | โ Great |
| Deep nested objects | โ ๏ธ Manual tracking | โ Automatic |
| Property change events | โ Not available | โ Full context |
| Structured validation | โ DIY | โ Mirrors data |
| Async validation | โ DIY | โ Built-in |
| Undo/Redo | โ DIY | โ Built-in |
| Dirty tracking | โ DIY | โ Automatic (per-field) |
| Action loading states | โ DIY | โ Built-in |
| State with methods | โ ๏ธ Manual cloning | โ Automatic |
svstate is for:
ISC ยฉ BCsabaEngine
Stop fighting with state. Start building features.
โญ Star us on GitHub if svstate helps your project!