Encrypted Storage & Loading States
The useStore hook persists state to IndexedDB and syncs across components and browser tabs. This guide covers loading tuples and opt-in encryption in the current Dim framework.
Prior tutorials (3. Async State, 4. Bottom-up Storage) describe the original event-driven design. This page documents what shipped in the framework today.
Loading states (4-tuple entries)
When you define a store with nested useState tuples, Dim enhances each leaf into a 4-tuple:
const {
cart: [cart, setCart, cartLoading, setCartLoading],
user: {
name: [name, setName, nameLoading],
},
} = useStore({
cart: useState([]),
user: {
name: useState(""),
},
});
| Index | Name | Role |
|---|---|---|
| 0 | value | Current state |
| 1 | setter | Update function (triggers persistence + events) |
| 2 | isLoading | true until initial IndexedDB read completes |
| 3 | setIsLoading | Rarely needed; managed by the async manager |
Loading UI pattern
const App = (props, { useStore, useState, html }) => {
const store = useStore({ items: useState([]) });
const [items, setItems, isLoading] = store.items;
if (isLoading) {
return html`<p class="skeleton">Loading cart…</p>`;
}
return html`<cart-view .props=${{ items, setItems }}></cart-view>`;
};
isLoading becomes false after a successful read, a missing key (defaults apply), or an error (logged to console).
Plaintext persistence (default)
Encryption is off by default. Data is stored as JSON-serializable values in IndexedDB (DimDatabase / DimStore):
useStore({
theme: useState("light"),
todos: useState([]),
});
Multiple components can subscribe to the same keys; updates propagate via window CustomEvents (see tutorial 3).
Encrypted persistence
Pass an explicit encryption key when you need AES-GCM at rest:
useStore(
{
secrets: useState({ token: "" }),
},
{ encryptionKey: "user-supplied-password" }
);
// Shorthand string form also works:
useStore({ count: useState(0) }, "my-password");
There is no default password. If you previously relied on an implicit framework password, re-encrypt or migrate data manually.
Crypto details
- Algorithm: AES-GCM via Web Crypto API
- Key derivation: PBKDF2 from the encryption key
- Salt: New writes include a per-record random
saltfield - Backward compatibility: Reads accept legacy payloads without
saltusing a deterministic fallback derived from the password
Encrypted on-disk shape:
{ "encryptedData": "...", "iv": "...", "salt": "..." }
Cross-tab and cross-component sync
The async manager (AsyncronousStateManager):
- Registers listeners per component instance (
randomId) - Dispatches debounced events on user updates
- Dispatches separate initial-load events (
${key}-initialLoad-${listenerId}) to avoid races on mount - Sets loading flags via internal
loadingSettersmap
Setters accept an internal flag to prevent update loops when applying remote values:
// Internal only — do not call from app code
setter(newValue, isInternalUpdate);
Advanced: direct manager access
Dim exports managers for custom integrations:
import {
CryptoManager,
StorageManager,
AsyncronousStateManager,
} from "../core/dim.ts";
Most apps should use useStore rather than these classes directly.
React bridge: useDimStore
React apps can read/write the same encrypted IndexedDB keys via useDimStore. See 12. useDimStore: Dim Storage from React.
Migration checklist
| Old behavior | New behavior |
|---|---|
| Default encryption password | Opt-in { encryptionKey } only |
[value, setter] only | [value, setter, isLoading, setIsLoading] |
Object attrs via new Function | JSON-only; use .props for functions |
{ encryptedData, iv } only | Optional salt on new writes |
Full details: MIGRATION.md.
Conclusion
Loading tuples make async hydration explicit in the UI. Opt-in encryption keeps local-first apps capable of protecting sensitive data without surprising defaults.