Skip to main content

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(""),
},
});
IndexNameRole
0valueCurrent state
1setterUpdate function (triggers persistence + events)
2isLoadingtrue until initial IndexedDB read completes
3setIsLoadingRarely 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 salt field
  • Backward compatibility: Reads accept legacy payloads without salt using a deterministic fallback derived from the password

Encrypted on-disk shape:

{ "encryptedData": "...", "iv": "...", "salt": "..." }

Cross-tab and cross-component sync

The async manager (AsyncronousStateManager):

  1. Registers listeners per component instance (randomId)
  2. Dispatches debounced events on user updates
  3. Dispatches separate initial-load events (${key}-initialLoad-${listenerId}) to avoid races on mount
  4. Sets loading flags via internal loadingSetters map

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 behaviorNew behavior
Default encryption passwordOpt-in { encryptionKey } only
[value, setter] only[value, setter, isLoading, setIsLoading]
Object attrs via new FunctionJSON-only; use .props for functions
{ encryptedData, iv } onlyOptional 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.