Understanding autoSave
Let's break down exactly what happens when you call autoSave, step by step.
The Full Example
const todos = Collections.create([
{ id: 1, text: 'Buy milk', done: false }
]);
autoSave(todos, 'my-todos');
// Add a todo — automatically saved
todos.add({ id: 2, text: 'Walk dog', done: false });
// Refresh the page → both todos are still there ✨Now let's understand each part.
Step-by-Step Breakdown
1️⃣ Calling autoSave
autoSave(todos, 'my-todos');What happens immediately:
autoSave(todos, 'my-todos')
↓
1️⃣ Validates arguments:
├── todos is an object? ✅
└── 'my-todos' is a string? ✅
↓
2️⃣ Parses options (using defaults):
├── storage: 'localStorage'
├── namespace: ''
├── debounce: 0
├── autoLoad: true
├── autoSave: true
├── sync: false
└── expires: null
↓
3️⃣ Checks storage availability:
└── localStorage available? ✅
↓
4️⃣ Creates a StorageWrapper:
└── new StorageWrapper('localStorage', '')
↓
5️⃣ AUTO-LOAD: Checks localStorage for key 'my-todos'
├── Found? → Parse JSON → restore into state
└── Not found? → Skip (use initial values)
↓
6️⃣ Sets up AUTO-SAVE effect:
└── effect(() => { getValue(todos); save(); })
↓
7️⃣ Attaches methods to todos:
├── todos.save()
├── todos.load()
├── todos.clear()
├── todos.exists()
├── todos.stopAutoSave()
├── todos.startAutoSave()
├── todos.destroy()
└── todos.storageInfo()
↓
8️⃣ Returns the reactive object (todos)2️⃣ The Auto-Load Phase
When autoLoad is true (the default), autoSave immediately checks localStorage:
// What autoSave does internally:
const loaded = store.get('my-todos');
if (loaded !== null) {
// Data found — restore it into the reactive state
setValue(todos, loaded);
}How the StorageWrapper stores data:
{
"value": [
{ "id": 1, "text": "Buy milk", "done": false },
{ "id": 2, "text": "Walk dog", "done": false }
],
"timestamp": 1708041600000
}Every stored item is wrapped in an object with value and timestamp. If expires is set, there's also an expires field.
3️⃣ The Auto-Save Effect
effectCleanup = effect(() => {
const _ = getValue(reactiveObj); // Read the state (triggers dependency tracking)
save(); // Save to storage
});This is the magic. By reading the state inside an effect(), the effect registers as a dependency. Whenever the state changes, the effect re-runs and saves.
What getValue() returns depends on the state type:
getValue(reactiveObj)
↓
Is it a ref? (has .value)
→ return obj.value
Is it a collection? (has .items)
→ return obj.items
Is it a form? (has .values)
→ return { values, errors, touched }
Is it a state? (has .raw)
→ return obj.raw
Otherwise:
→ return obj4️⃣ When State Changes
todos.add({ id: 2, text: 'Walk dog', done: false });What happens:
todos.add(item)
↓
items array changes (reactive)
↓
Auto-save effect re-runs:
↓
1️⃣ getValue(todos) → reads todos.items → [item1, item2]
↓
2️⃣ save() is called:
├── Check: is this a save from storage sync? No → proceed
├── Check: was the last save < 100ms ago? No → proceed
├── Debounce? (debounce = 0) → save immediately
↓
3️⃣ doSave():
├── Get value to save
├── Run onSave callback (if provided)
├── Stringify with circular reference protection
├── Check size (warn if > 100KB)
├── Check total storage size (warn if > 5MB)
└── store.set('my-todos', value, { expires })
↓
4️⃣ localStorage now contains:
key: 'my-todos'
value: '{"value":[...items...],"timestamp":1708041600000}'5️⃣ On Page Refresh
When the page loads again and autoSave is called:
Page loads → autoSave(todos, 'my-todos') called
↓
Auto-load phase:
↓
store.get('my-todos')
↓
1️⃣ Reads from localStorage
↓
2️⃣ JSON.parse the stored string
↓
3️⃣ Checks expiration:
├── Has expires field?
│ ├── YES → Is Date.now() > expires?
│ │ ├── YES → Remove from storage, return null (expired)
│ │ └── NO → Continue
│ └── NO → Continue
↓
4️⃣ Returns the stored value
↓
5️⃣ setValue(todos, loadedValue):
└── todos is a collection → calls todos.reset(loadedValue)
↓
6️⃣ State is restored ✅How Different State Types Are Saved and Loaded
Reactive State
const state = state({ name: 'Alice', age: 30 });
autoSave(state, 'user');
// Saved: { name: 'Alice', age: 30 }
// Loaded: Object.assign(state, savedValue)Ref
const count = ref(0);
autoSave(count, 'counter');
// Saved: 0 (the .value)
// Loaded: count.value = savedValueCollection
const todos = Collections.create([]);
autoSave(todos, 'todos');
// Saved: [...items]
// Loaded: todos.reset(savedItems)Form
const form = Forms.create({ name: '', email: '' });
autoSave(form, 'draft');
// Saved: { values: {...}, errors: {...}, touched: {...} }
// Loaded: Object.assign(form.values, saved.values), etc.The Save Throttle
To prevent excessive writes, saves are throttled to a minimum of 100ms apart:
state.x = 1; // Save! (first save)
state.y = 2; // Save attempted, but < 100ms → skipped
state.z = 3; // Save attempted, but < 100ms → skipped
... 100ms pass ...
state.w = 4; // Save! (enough time has passed)This is separate from the debounce option. The throttle is always active as a safety net.
The Flush on Unload
If the user closes the tab while a debounced save is pending, autoSave registers a beforeunload handler to flush it:
User closes tab
↓
beforeunload event fires
↓
Is there a pending debounced save? (saveTimeout exists?)
├── YES → Clear timeout, save immediately
└── NO → Nothing to doThis ensures that the very last state change is always persisted, even with debouncing.
Common Mistakes
❌ Calling autoSave before the state is set up
// ❌ autoSave loads data IMMEDIATELY — so set up your state first
const state = state({});
autoSave(state, 'data');
// Loaded data may have keys that weren't in the initial state
// ✅ Define all expected keys upfront
const state = state({ name: '', email: '', theme: 'dark' });
autoSave(state, 'data');❌ Using the same key for different states
// ❌ Both states overwrite each other's data
autoSave(settings, 'app-data');
autoSave(todos, 'app-data'); // Same key!
// ✅ Use unique keys
autoSave(settings, 'settings');
autoSave(todos, 'todos');
// ✅ Or use namespaces
autoSave(settings, 'settings', { namespace: 'myApp' });
autoSave(todos, 'todos', { namespace: 'myApp' });
// Stored as: 'myApp:settings' and 'myApp:todos'❌ Storing non-serializable data
// ❌ Functions, DOM elements, and class instances can't be JSON-serialized
const state = state({
handler: () => console.log('hi'), // Function — can't serialize
element: document.body // DOM node — can't serialize
});
autoSave(state, 'data');
// ✅ Only store plain, serializable data
const state = state({
name: 'Alice',
score: 100,
tags: ['js', 'css']
});❌ Forgetting to call destroy when removing dynamic components
// ❌ Event listeners and effects leak
function createWidget() {
const state = state({ count: 0 });
autoSave(state, 'widget');
return state;
}
// Widget is removed, but the effect and listeners are still active
// ✅ Call destroy when done
function createWidget() {
const state = state({ count: 0 });
autoSave(state, 'widget');
return state;
}
const widget = createWidget();
// Later, when removing:
widget.destroy(); // Cleans up effect, storage listener, unload listenerKey Takeaways
- Auto-load happens first — saved data is restored before any effects run
- Auto-save uses an effect — reading the state inside the effect triggers dependency tracking
- getValue/setValue detect the state type (ref, collection, form, state) automatically
- Save throttle — minimum 100ms between saves as a safety net
- Flush on unload — pending debounced saves are flushed when the tab closes
- StorageWrapper wraps data with
{ value, timestamp, expires }for expiration support - Methods added —
save,load,clear,exists,destroy, etc.
What's next?
Let's explore all the options, methods, and production hardening features of autoSave.
Let's continue!