Local Storage Persistence

XMLUI can automatically persist data to the browser's localStorage and restore it on the next page load — no server, no custom logic required. There are two levels of persistence:

  • App-level: the active theme and tone persist across reloads.
  • Variable-level: any <global> variable can be pinned to a localStorage key.

Both levels are completely opt-in. Apps that don't declare any persistence continue to work exactly as before.

Persisting the app theme and tone

Set persistTheme="true" on the <App> component. XMLUI will save the active theme ID and tone to localStorage and restore them on the next load — no flash, no two-phase init.

<App persistTheme="true">
  ...
</App>

By default the values are stored under the keys appTheme and appTone. You can override these with themeStorageKey and toneStorageKey:

<App
  persistTheme="true"
  themeStorageKey="myApp.theme"
  toneStorageKey="myApp.tone"
>
  ...
</App>

persistTheme covers both the theme selection and the light/dark tone toggle in one prop. The two storage keys let you namespace them inside a shared object if you prefer dot-path semantics.

Persisting global variables

With an explicit storage key

Pass storageKey on the <global> tag. On every page load the variable is initialised from localStorage (falling back to value when nothing is stored yet), and every change writes back automatically.

<App>
  <global name="count" value="{0}" storageKey="count" />
  <VStack>
    <Text>Count: {count}</Text>
    <HStack gap="$space-2">
      <Button label="Increment" onClick="count++" />
      <Button label="Decrement" onClick="count--" />
    </HStack>
    <Text>
      Reload the app (with <Icon name="refresh" />) to see the count restored.
    </Text>
  </VStack>
</App>
Example: Persisting global variable
<App>
  <global name="count" value="{0}" storageKey="count" />
  <VStack>
    <Text>Count: {count}</Text>
    <HStack gap="$space-2">
      <Button label="Increment" onClick="count++" />
      <Button label="Decrement" onClick="count--" />
    </HStack>
    <Text>
      Reload the app (with <Icon name="refresh" />) to see the count restored.
    </Text>
  </VStack>
</App>

With the persist shorthand

When storageKey is omitted, persist="true" uses the variable's own name as the key:

<!-- These two are equivalent -->
<global name="count" value="{0}" persist="true" />
<global name="count" value="{0}" storageKey="count" />

An explicit storageKey always wins over the global variable name:

<!-- Key used is "myApp.v1.count", not "count" -->
<global name="count" value="{0}" persist="true" storageKey="myApp.v1.count" />

How it works

SituationBehaviour
First run — nothing stored yetVariable starts at the value attribute.
Subsequent runs — value found in storageVariable starts at the stored value. value is ignored.
Storage read fails (corrupt data, SecurityError)Variable starts at the value attribute.
Variable changes at runtimeNew value written to localStorage immediately.

The read happens synchronously during state initialisation, so the variable holds its correct persisted value on the very first render — no UI flash.

Reading and writing storage manually

Five global functions are available in XMLUI scripts for direct localStorage access:

readLocalStorage(key, fallback?)

Reads a value from localStorage. The key uses dot-path semantics: the first segment is the entry name, the rest is a property path inside the parsed JSON object.

<App>
  <VStack>
    <Button 
      label="Write 'Hello'" 
      onClick="writeLocalStorage('demo.greeting', 'Hello!')"
    />
    <Button 
      label="Write 42" 
      onClick="writeLocalStorage('demo.number', 42)" 
    />
    <Button
      label="Read greeting"
      onClick="toast.success(readLocalStorage('demo.greeting', '(nothing stored)'))"
    />
    <Button
      label="Read full entry"
      onClick="toast.success(JSON.stringify(readLocalStorage('demo')))"
    />
  </VStack>
</App>
Example: readLocalStorage
<App>
  <VStack>
    <Button 
      label="Write 'Hello'" 
      onClick="writeLocalStorage('demo.greeting', 'Hello!')"
    />
    <Button 
      label="Write 42" 
      onClick="writeLocalStorage('demo.number', 42)" 
    />
    <Button
      label="Read greeting"
      onClick="toast.success(readLocalStorage('demo.greeting', '(nothing stored)'))"
    />
    <Button
      label="Read full entry"
      onClick="toast.success(JSON.stringify(readLocalStorage('demo')))"
    />
  </VStack>
</App>

writeLocalStorage(key, value)

Writes a value. For a simple key the value replaces the whole entry; for a dot-path key it merges into the existing object:

<!-- Stores {"theme":"dark","tone":"light"} under "prefs" -->
writeLocalStorage("prefs.theme", "dark")
writeLocalStorage("prefs.tone", "light")

deleteLocalStorage(key)

Removes a single entry or a sub-path within an entry:

deleteLocalStorage("count")       // removes the "count" entry entirely
deleteLocalStorage("prefs.tone")  // removes only prefs.tone from the "prefs" entry

getAllLocalStorage()

Returns every entry currently in localStorage as a plain object (values JSON-parsed where possible):

<App>
  <VStack>
    <Button 
      label="Write some values" 
      onClick="
        writeLocalStorage('example.x', 1); 
        writeLocalStorage('example.y', 2)" 
      />
    <Button
      label="Show all entries"
      onClick="toast.success(JSON.stringify(getAllLocalStorage()))"
    />
  </VStack>
</App>
Example: getAllLocalStorage
<App>
  <VStack>
    <Button 
      label="Write some values" 
      onClick="
        writeLocalStorage('example.x', 1); 
        writeLocalStorage('example.y', 2)" 
      />
    <Button
      label="Show all entries"
      onClick="toast.success(JSON.stringify(getAllLocalStorage()))"
    />
  </VStack>
</App>

Resetting persisted data

From a button or script: resetLocalStorage(prefix?)

Call resetLocalStorage() to wipe all localStorage entries, or pass a prefix to remove only matching keys:

<App>
  <global name="count" value="{0}" storageKey="demo.count" />
  <VStack>
    <Text>Count: {count}</Text>
    <Button label="Increment" onClick="count++" />
    <Button
      label="Reset count to default"
      onClick="resetLocalStorage('demo.count'); count = 0"
    />
    <Button
      label="Reset ALL storage"
      onClick="resetLocalStorage()"
    />
  </VStack>
</App>
Example: resetLocalStorage
<App>
  <global name="count" value="{0}" storageKey="demo.count" />
  <VStack>
    <Text>Count: {count}</Text>
    <Button label="Increment" onClick="count++" />
    <Button
      label="Reset count to default"
      onClick="resetLocalStorage('demo.count'); count = 0"
    />
    <Button
      label="Reset ALL storage"
      onClick="resetLocalStorage()"
    />
  </VStack>
</App>

resetLocalStorage removes the entry from localStorage but does not automatically reset the in-memory variable — set it explicitly if you want the UI to reflect the default immediately (as shown above).

Via URL: ?xmlui-reset

Navigate to the app with ?xmlui-reset appended. XMLUI clears localStorage before rendering starts — an effective escape hatch even when a bad persisted value prevents the app from loading at all:

URLEffect
https://myapp.com/?xmlui-resetClear all localStorage for this app
https://myapp.com/?xmlui-reset=countClear only the count entry
https://myapp.com/?xmlui-reset=myApp.v1Clear all entries whose key starts with myApp.v1

The parameter is self-removing via history.replaceState — it fires exactly once and is not visible in the URL afterwards.

From the browser console

Two console helpers are available on window:

// Inspect what is persisted
window.XMLUI_GET_STORAGE()
// → { count: 5, appTheme: "dark", appTone: "dark" }

// Clear everything and reload
window.XMLUI_RESET_STORAGE()

// Clear only a prefix and reload
window.XMLUI_RESET_STORAGE("myApp.v1")

Schema versioning

When you ship a new version of your app with an incompatible data shape, clients that previously stored the old format will receive the old value on load. The safest fix is to change the storage key by including a version in the prefix:

<!-- v1 of the app -->
<global name="prefs" value="{{}}" storageKey="myApp.v1.prefs" />

<!-- v2: breaking change in the data shape — bump the version -->
<global name="prefs" value="{{}}" storageKey="myApp.v2.prefs" />

Old myApp.v1.* keys are silently ignored (the variable starts from value) and can be cleaned up with clearLocalStorage("myApp.v1") in a migration script or a one-time startup check.

The storageKey attribute is intentionally named without a "local" prefix so the same attribute can be reused by future providers (sessionStorage, IndexedDB, or a remote KV store) without a breaking rename.

Quick reference

<global> attributes

AttributeTypeDefaultDescription
storageKeystringExplicit dot-path key into localStorage. Implies persistence.
persistbooleanfalseWhen true, uses the variable name as the storage key.

<App> props

PropTypeDefaultDescription
persistThemebooleanfalsePersist the active theme ID and tone across page loads.
themeStorageKeystring"appTheme"localStorage key for the theme ID.
toneStorageKeystring"appTone"localStorage key for the tone ("light" / "dark").

Global functions

FunctionSignatureDescription
readLocalStorage(key, fallback?) → anyRead a value (dot-path supported). Returns fallback on any error.
writeLocalStorage(key, value) → voidWrite a value (dot-path merges into the root entry).
deleteLocalStorage(key) → voidRemove an entry or sub-path.
resetLocalStorage(prefix?) → voidRemove all entries, or only those matching a prefix.
clearLocalStorage(prefix?) → voidAlias for resetLocalStorage.
getAllLocalStorage() → objectReturn all entries as a plain object.