Common questions and answers about svstate.
- General Questions
- Validation
- Async Validation
- Effects & Side Effects
- Snapshots & Undo
- Actions
- TypeScript & Types
- Plugins
- Troubleshooting
svstate is a Svelte 5 library that provides a supercharged $state() with deep reactive proxies. It solves the problem of managing complex, nested state objects by providing:
- Automatic change detection at any nesting level
- Structured validation that mirrors your data shape
- Async validation for server-side checks (username availability, email verification)
- Property change events with full context (what changed, old/new values)
- Snapshot-based undo/redo system
- Dirty state tracking (aggregate and per-field)
- Plugin system for persistence, auto-save, devtools, URL sync, cross-tab sync, undo/redo, and analytics
Use it when: You have complex forms, ERP/CRM applications, or any state beyond simple username/password fields.
Use plain $state() for:
- Simple, flat objects (login forms, toggles)
- State that doesn't need validation
- Temporary UI state
Use svstate for:
- Deeply nested objects (customer records, product catalogs)
- Forms requiring real-time validation
- Applications needing undo/redo functionality
- State that triggers side effects on changes
- Tracking dirty state for "unsaved changes" warnings
// Plain $state is fine for this
const loginForm = $state({ username: '', password: '' });
// svstate shines for this
const customer = $state({
name: 'Acme Corp',
addresses: [{ type: 'billing', street: '123 Main St', city: 'NYC' }],
billing: { bankAccount: { iban: '', swift: '' } }
});svstate requires Svelte 5 because it uses the $state() rune internally. It is not compatible with Svelte 4's store-based reactivity.
Requirements:
- Svelte 5
- Node.js >= 20
- npm >= 9
Yes! svstate works seamlessly with SvelteKit. Use it in your +page.svelte or component files just like any other Svelte 5 state:
<script lang="ts">
import { createSvState, stringValidator } from 'svstate';
const { data, state: { errors, hasErrors } } = createSvState(
{ email: '', name: '' },
{
validator: (source) => ({
email: stringValidator(source.email).prepare('trim').required().email().getError(),
name: stringValidator(source.name).prepare('trim').required().getError()
})
}
);
</script>
<input bind:value={data.email} />
{#if $errors?.email}<span class="error">{$errors.email}</span>{/if}svstate tracks which specific fields have been modified via the isDirtyByField store. It returns a DirtyFields object where keys are dot-notation property paths and values are true:
const {
data,
state: { isDirty, isDirtyByField }
} = createSvState({
name: '',
address: { street: '', city: '' }
});
data.address.street = '123 Main St';
// $isDirtyByField → { "address.street": true, "address": true }
// $isDirty → true (derived from isDirtyByField)Key behaviors:
- When a nested field changes, all parent paths are also marked dirty (e.g., changing
address.streetalso marksaddressas dirty) isDirtyis derived fromisDirtyByField— it'struewhen any field is dirty- Cleared on
reset(),rollback(), and successful action (respectingresetDirtyOnAction) - Useful for highlighting changed fields in the UI or showing "unsaved changes" per section
<!-- Highlight changed fields -->
<input
bind:value={data.name}
class:modified={$isDirtyByField['name']}
/>
<!-- Show section-level dirty indicator -->
{#if $isDirtyByField['address']}
<span class="badge">Modified</span>
{/if}Validation runs automatically whenever any property changes. By default, it's debounced using queueMicrotask() to batch multiple rapid changes into a single validation pass.
const {
data,
state: { errors }
} = createSvState(
{ email: '' },
{
validator: (source) => ({
email: stringValidator(source.email).required().email().getError()
})
}
);
// Changing data.email triggers validation automatically
data.email = 'invalid'; // $errors.email = "Invalid email format"
data.email = 'valid@example.com'; // $errors.email = ""Customize debouncing:
createSvState(data, actuators, {
debounceValidation: 300 // Wait 300ms after last change
});Yes! The validator function just needs to return an object matching your error structure. Use any validation library:
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
age: z.number().min(18)
});
const {
data,
state: { errors }
} = createSvState(
{ email: '', age: 0 },
{
validator: (source) => {
const result = schema.safeParse(source);
if (result.success) return { email: '', age: '' };
const fieldErrors = result.error.flatten().fieldErrors;
return {
email: fieldErrors.email?.[0] ?? '',
age: fieldErrors.age?.[0] ?? ''
};
}
}
);This design provides type safety and intuitive access to errors. When your error object has the same shape as your data, you can access errors using the same paths:
// Data structure
const data = {
address: {
street: '',
city: ''
}
};
// Error structure mirrors it
const errors = {
address: {
street: 'Required',
city: ''
}
};
// Access is intuitive
$errors?.address?.street; // "Required"
$errors?.address?.city; // ""This also enables TypeScript to provide autocomplete for error paths.
Use arrayValidator for the array itself. For validating individual items, map over them in your validator:
const {
data,
state: { errors }
} = createSvState(
{
tags: ['svelte', 'typescript'],
contacts: [{ name: '', email: '' }]
},
{
validator: (source) => ({
// Validate the array itself
tags: arrayValidator(source.tags).minLength(1).maxLength(10).unique().getError(),
// Validate each contact item
contacts: source.contacts.map((contact) => ({
name: stringValidator(contact.name).prepare('trim').required().getError(),
email: stringValidator(contact.email).prepare('trim').required().email().getError()
}))
})
}
);Use asyncValidator — a map of property paths to async functions. Each function receives the current value, the full source object, and an AbortSignal for cancellation:
import { createSvState, stringValidator, type AsyncValidator } from 'svstate';
type UserForm = { username: string; email: string };
const asyncValidators: AsyncValidator<UserForm> = {
username: async (value, source, signal) => {
if (!value) return ''; // Let sync validation handle required
const res = await fetch(`/api/check-username?name=${value}`, { signal });
const { available } = await res.json();
return available ? '' : 'Username already taken';
},
email: async (value, source, signal) => {
if (!value) return '';
const res = await fetch(`/api/check-email?email=${value}`, { signal });
const { valid } = await res.json();
return valid ? '' : 'Email not deliverable';
}
};
const {
data,
state: { errors, asyncErrors, asyncValidating, 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 }
);<!-- Show loading indicator while checking -->
{#if $asyncValidating.includes('username')}
<span>Checking...</span>
{/if}
<!-- Show async error -->
{#if $asyncErrors.username}
<span class="error">{$asyncErrors.username}</span>
{/if}
<!-- Disable submit when any errors exist (sync OR async) -->
<button disabled={$hasCombinedErrors}>Submit</button>| Store | Type | Description |
|---|---|---|
asyncErrors |
Readable<AsyncErrors> |
Error strings keyed by property path |
hasAsyncErrors |
Readable<boolean> |
True if any async error string is non-empty |
asyncValidating |
Readable<string[]> |
Property paths currently being validated |
hasCombinedErrors |
Readable<boolean> |
True if any sync OR async error exists |
- Async validators only run if sync validation passes for that path
- When a property changes, pending async validation for that path is cancelled (via
AbortSignal) rollback()andreset()cancel all pending async validations and clear async errors- Use
debounceAsyncValidation(default: 300ms) to avoid excessive API calls during typing - Use
maxConcurrentAsyncValidations(default: 4) to limit parallel requests
Async validators use dot-notation paths and trigger based on these matching rules:
- Exact match: validator for
"email"triggers whenemailchanges - Parent triggers child: validator for
"user.email"triggers whenuserchanges - Child triggers parent: validator for
"user"triggers whenuser.emailchanges
The effect callback fires whenever any property changes, giving you full context about what changed. Use it for:
- Creating undo snapshots
- Logging/analytics
- Cross-field updates
- Triggering API calls on specific changes
const { data } = createSvState(formData, {
effect: ({ target, property, currentValue, oldValue, snapshot }) => {
console.log(`${property}: ${oldValue} → ${currentValue}`);
// Create snapshot for undo
snapshot(`Changed ${property}`);
// Trigger side effect for specific field
if (property === 'country') {
loadTaxRates(currentValue);
}
}
});No. The effect callback must be synchronous. If you return a Promise, svstate throws an error.
For async operations, use the action instead:
// ❌ Wrong - will throw error
effect: async ({ property }) => {
await saveToServer(); // Error!
};
// ✅ Correct - use action for async
action: async () => {
await saveToServer();
};The property is a dot-notation path string:
| Change | Property Path |
|---|---|
data.name = 'John' |
"name" |
data.address.city = 'NYC' |
"address.city" |
data.billing.bank.iban = '...' |
"billing.bank.iban" |
data.contacts[0].email = '...' |
"contacts.email" |
data.tags.push('new') |
"tags" |
Note: Array indices are collapsed — you get "contacts.email" not "contacts.0.email".
Call snapshot(title) in your effect to create undo points. Each snapshot stores a deep clone of the current state:
const {
data,
rollback,
reset,
state: { snapshots }
} = createSvState(formData, {
effect: ({ snapshot, property }) => {
snapshot(`Changed ${property}`);
}
});
// Make changes
data.name = 'New Name'; // Creates snapshot "Changed name"
data.email = 'new@example.com'; // Creates snapshot "Changed email"
// Undo
rollback(); // Reverts to "Changed name" state
rollback(2); // Reverts 2 steps back
// Reset to initial
reset(); // Returns to original stateWhen replace is true (default), consecutive snapshots with the same title replace each other instead of stacking. This prevents snapshot bloat during rapid typing:
effect: ({ snapshot }) => {
// User types "Hello" quickly
// Without replace: 5 snapshots ("H", "He", "Hel", "Hell", "Hello")
// With replace: 1 snapshot ("Hello")
snapshot('Typing in name field'); // Same title = replaces previous
};
// Force new snapshot even with same title
snapshot('Important change', false);Use rollbackTo(title) to jump directly to the last snapshot with a matching title. It returns true if found, false otherwise:
const { data, rollback, rollbackTo, reset } = createSvState(formData, {
effect: ({ snapshot, property }) => {
snapshot(`Changed ${property}`);
}
});
// Jump to the last snapshot with this exact title
const found = rollbackTo('Changed email'); // true if found
// Roll back to the very beginning
rollbackTo('Initial');
// One step undo
rollback();
// Undo 3 steps
rollback(3);Note: rollbackTo searches from the most recent snapshot backwards and returns false if no matching snapshot exists or only the initial snapshot remains.
Yes. Both rollback(), rollbackTo(), and reset() trigger validation after restoring state, ensuring your error state stays in sync with your data.
| Feature | Effect | Action |
|---|---|---|
| Trigger | Automatically on any property change | Manually via execute() |
| Async | Must be synchronous | Can be async |
| Purpose | Side effects, snapshots | Submit data to backend |
| Frequency | Fires on every change | Fires once per execute() call |
const { data, execute } = createSvState(formData, {
// Effect: runs on every change (sync only)
effect: ({ snapshot, property }) => {
snapshot(`Changed ${property}`);
},
// Action: runs when you call execute() (can be async)
action: async () => {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data)
});
}
});
// Trigger action manually
await execute();Use the actionInProgress store:
<script>
const { execute, state: { actionInProgress, hasErrors } } = createSvState(/* ... */);
</script>
<button onclick={() => execute()} disabled={$hasErrors || $actionInProgress}>
{$actionInProgress ? 'Saving...' : 'Save'}
</button>Yes! Define a parameter type and pass values to execute():
type SaveParams = { draft?: boolean; redirect?: string };
const { execute } = createSvState<FormData, FormErrors, SaveParams>(formData, {
action: async (params) => {
await saveToServer({ ...data, isDraft: params?.draft });
if (params?.redirect) goto(params.redirect);
}
});
// Different buttons, different behaviors
execute({ draft: true }); // Save as draft
execute({ draft: false, redirect: '/list' }); // Publish and redirect
execute(); // Default savesvstate exports these types for building type-safe external functions:
import type {
Validator,
EffectContext,
Snapshot,
SnapshotFunction,
SvStateOptions,
AsyncValidator,
AsyncValidatorFunction,
AsyncErrors,
DirtyFields,
SvStatePlugin,
PluginContext,
PluginStores,
ChangeEvent,
ActionEvent
} from 'svstate';
// Plugin-specific types
import type { AnalyticsEvent } from 'svstate';| Type | Use Case |
|---|---|
Validator |
Type for validation error objects |
EffectContext<T> |
Type effect callbacks when defined externally |
SnapshotFunction |
Type for the snapshot function parameter |
Snapshot<T> |
Type for snapshot history entries |
SvStateOptions |
Type for configuration options |
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 paths to dirty status |
SvStatePlugin<T> |
Plugin interface with lifecycle hooks |
PluginContext<T> |
Context passed to onInit: { data, state, options, snapshot } |
PluginStores<T> |
All readable stores exposed to plugins |
ChangeEvent<T> |
Payload for onChange: { target, property, currentValue, oldValue } |
ActionEvent |
Payload for onAction: { phase, params?, error? } |
AnalyticsEvent |
Event object buffered by analyticsPlugin |
Example:
// External validator function
const validateUser = (source: UserData): UserErrors => ({
name: stringValidator(source.name).required().getError(),
email: stringValidator(source.email).required().email().getError()
});
// External effect function
const userEffect = ({ snapshot, property }: EffectContext<UserData>) => {
snapshot(`Updated ${property}`);
};
const { data } = createSvState(userData, {
validator: validateUser,
effect: userEffect
});Plugins extend createSvState with reusable behaviors via lifecycle hooks. Use them when you need cross-cutting concerns like persistence, auto-saving, debugging, or analytics — without cluttering your effect or action callbacks.
Plugins are passed via the plugins option array:
import { createSvState, persistPlugin, devtoolsPlugin } from 'svstate';
const { data, destroy } = createSvState(formData, actuators, {
plugins: [persistPlugin({ key: 'my-form' }), devtoolsPlugin({ name: 'MyForm' })]
});
// Call destroy() to clean up plugin resources (e.g., in onDestroy)
destroy();svstate ships with 7 built-in plugins:
| Plugin | Purpose | Import |
|---|---|---|
persistPlugin |
Persist state to localStorage/custom storage | import { persistPlugin } from 'svstate' |
autosavePlugin |
Auto-save after idle/interval | import { autosavePlugin } from 'svstate' |
devtoolsPlugin |
Console logging of all events | import { devtoolsPlugin } from 'svstate' |
historyPlugin |
Sync state fields to URL params | import { historyPlugin } from 'svstate' |
syncPlugin |
Cross-tab sync via BroadcastChannel | import { syncPlugin } from 'svstate' |
undoRedoPlugin |
Redo stack on top of built-in rollback | import { undoRedoPlugin } from 'svstate' |
analyticsPlugin |
Batch event buffering for analytics | import { analyticsPlugin } from 'svstate' |
Use persistPlugin to automatically save and restore state:
import { persistPlugin } from 'svstate';
const persist = persistPlugin({
key: 'my-form', // Required: storage key
throttle: 300, // Write debounce ms (default: 300)
exclude: ['password'], // Don't persist these fields
include: ['name', 'email'] // Only persist these fields (mutually exclusive with exclude)
});
const { data, reset } = createSvState(formData, actuators, {
plugins: [persist]
});
// Check if state was restored from storage
persist.isRestored(); // true/false
// Clear persisted data
persist.clearPersistedState();Reload the page and your state will be automatically restored.
The built-in rollback() provides undo. Add undoRedoPlugin for redo:
import { undoRedoPlugin } from 'svstate';
const undoRedo = undoRedoPlugin();
const { data, rollback } = createSvState(
formData,
{
effect: ({ snapshot, property }) => {
snapshot(`Changed ${property}`);
}
},
{ plugins: [undoRedo] }
);
// Undo (built-in)
rollback();
// Redo (from plugin)
undoRedo.redo();
// Check if redo is available
undoRedo.canRedo(); // boolean
// Reactive redo stack
undoRedo.redoStack; // Readable<Snapshot[]>Use syncPlugin which uses BroadcastChannel to sync state changes:
import { syncPlugin } from 'svstate';
const sync = syncPlugin({
key: 'my-form-sync', // Required: channel name
throttle: 100 // Broadcast debounce ms (default: 100)
});
const { data } = createSvState(formData, actuators, {
plugins: [sync]
});
// Changes in one tab automatically appear in all other tabs with the same keySerialization limitations: State is serialized with JSON.stringify before broadcasting, so some types are not preserved:
| Type | Behaviour after sync |
|---|---|
Date |
Becomes a string — re-parse if needed |
undefined values |
Dropped from the object |
| Functions / Symbols | Dropped silently |
| Plain objects / arrays / primitives | Fully preserved |
If your state contains Date fields, convert them back after receiving (e.g. in your validator or an effect).
Incoming messages deeper than 10 levels of nesting are rejected to prevent payload abuse.
Implement the SvStatePlugin<T> interface — all hooks are optional:
import type { SvStatePlugin, ChangeEvent } from 'svstate';
const myPlugin: SvStatePlugin<MyState> = {
name: 'my-plugin',
onInit(context) {
// Access: context.data, context.state, context.options, context.snapshot
},
onChange(event) {
console.log(`${event.property}: ${event.oldValue} → ${event.currentValue}`);
},
onValidation(errors) {
/* Called after sync validation */
},
onSnapshot(snapshot) {
/* Called when snapshot is created */
},
onAction(event) {
if (event.phase === 'before') {
/* Action starting */
}
if (event.phase === 'after') {
/* Action done, check event.error */
}
},
onRollback(snapshot) {
/* Called after rollback */
},
onReset() {
/* Called after reset */
},
destroy() {
/* Cleanup resources */
}
};Hook execution order: Hooks run in plugin array order (first to last), except destroy which runs last-to-first.
Yes! Plugins are composed via the plugins array. They run independently and don't interfere with each other:
const { data } = createSvState(formData, actuators, {
plugins: [
persistPlugin({ key: 'my-form' }),
syncPlugin({ key: 'my-form-sync' }),
autosavePlugin({ save: (d) => api.saveDraft(d), idle: 2000 }),
devtoolsPlugin({ name: 'MyForm' }),
analyticsPlugin({ onFlush: (events) => sendToAnalytics(events) })
]
});Common causes:
-
Setting same value: svstate uses strict equality (
!==) to skip unchanged values:data.name = 'John'; data.name = 'John'; // No effect triggered (same value)
-
Mutating non-proxied types: Date, Map, Set, RegExp, Error, and Promise are not proxied:
data.date.setFullYear(2025); // Won't trigger effect data.date = new Date(2025, 0, 1); // Will trigger effect
-
Replacing the entire object: Assign to properties, not the whole object:
data = { name: 'New' }; // Won't work Object.assign(data, { name: 'New' }); // Works data.name = 'New'; // Works
The hasErrors store recursively checks if any leaf string in your error object is non-empty. Check that:
- All error paths return empty strings (
'') when valid, notundefinedornull - Your validator returns the complete error structure even when valid
// ❌ Wrong - undefined values
validator: (source) => ({
name: source.name ? undefined : 'Required' // Don't use undefined
});
// ✅ Correct - empty strings
validator: (source) => ({
name: stringValidator(source.name).required().getError() // Returns '' when valid
});The easiest way is to use the built-in devtoolsPlugin, which logs all state events to the browser console:
import { createSvState, devtoolsPlugin } from 'svstate';
const { data } = createSvState(formData, actuators, {
plugins: [
devtoolsPlugin({
name: 'MyForm', // Label in console output
logValidation: true // Also log validation results
})
]
});For more granular debugging, add logging to your effect:
effect: ({ target, property, currentValue, oldValue, snapshot }) => {
console.log('[svstate]', {
property,
from: oldValue,
to: currentValue,
fullState: JSON.parse(JSON.stringify(target))
});
snapshot(`Changed ${property}`);
};For validation debugging, log the full error object:
$effect(() => {
console.log('Errors:', $errors);
console.log('Has errors:', $hasErrors);
});By default, svstate prevents concurrent action execution. If actionInProgress is true, subsequent execute() calls are ignored.
Solutions:
-
Wait for completion: Ensure previous action finished before calling again
-
Allow concurrent actions:
createSvState(data, actuators, { allowConcurrentActions: true });
-
Check status before calling:
<button disabled={$actionInProgress} onclick={() => execute()}> Save </button>
- Documentation: See README.md for comprehensive guides
- Issues: GitHub Issues
- Live Demo: Try it in your browser