Storage

localStorage keeps small values on the device — a setting, the progress, a draft — as strings under keys, still there when the app opens again. Small values that must survive a restart go here or into a stored signal (signal(x).stored(key), signals); documents and binary files go to files (desktop and iOS); data shared between devices or users goes to the backend database (server code).

  • remember a setting between launches (signal(x).stored(key))
  • save game progress or a high score on the device
  • show the onboarding only on the first launch
  • keep a list or an object between launches (as JSON)
  • forget a saved value or reset the progress (removeItem)

At a glance

TypeScript
type Save = { level: number, coins: number }

// save: a string under a key, still there the next time the app opens
localStorage.setItem('save', JSON.stringify({ level: 3, coins: 120 }))

// load: null when nothing was saved (the first launch), so fall back to a default
const raw = localStorage.getItem('save')
const save: Save = raw ? JSON.parse(raw) : { level: 1, coins: 0 }
console.log('level', save.level, 'coins', save.coins)

// forget
localStorage.removeItem('save')

Save progress or a high score: setItem / getItem / removeItem

The high score, the level reached or any value kept for the next launch is a string under a key: setItem writes it, getItem reads it back (null while nothing is saved under the key — the first launch), removeItem forgets it.

TypeScript
localStorage.setItem(key: string, value: string): void   // store or overwrite
localStorage.getItem(key: string): string | null          // null = nothing saved under the key
localStorage.removeItem(key: string): void                // delete; a key that is not there is fine

That is the whole surface — no clear(), no length, no key(i): to reset everything, remove the keys you wrote.

TypeScript
let best = Number(localStorage.getItem('best') ?? 0)     // 0 on the first launch

const gameOver = (score: number) => {
  if (score <= best) return
  best = score
  localStorage.setItem('best', String(best))             // values are strings: String() a number
}

const resetProgress = () => {
  best = 0
  localStorage.removeItem('best')                        // getItem('best') is null again
}

gameOver(120)
gameOver(80)
console.log('best', localStorage.getItem('best'))        // "120"
resetProgress()
console.log('after reset', localStorage.getItem('best')) // null
Note

values are strings only — String(n) a number and Number(text) it back. A non-string passed past the types is stored as its string form (5 reads back as "5").

Keep a list or an object: JSON

A list or an object is kept as JSON text under one key: JSON.stringify on the way in, JSON.parse on the way out, and the whole list written back after each change. A stored signal does this for you (below).

TypeScript
type Note = { id: number, text: string }

const loadNotes = (): Note[] => {
  try { return JSON.parse(localStorage.getItem('notes') ?? '[]') }
  catch { return [] }                                    // not JSON under the key: start empty
}
const saveNotes = (list: Note[]) => localStorage.setItem('notes', JSON.stringify(list))

const notes = loadNotes()
notes.push({ id: Date.now(), text: 'Buy milk' })
saveNotes(notes)                                         // the whole list goes back under the key
console.log('notes', loadNotes().length, loadNotes()[0].text)

Remember a setting between launches: signal(x).stored(key)

A setting that must still be there after the app is closed and opened again — sound on, the theme, the last tab — is a stored signal: signal(initial).stored(key) starts from what the last run saved under key (the initial value on the first launch), and every write is saved at once. More in signals.

TypeScript
const soundOn = signal(true).stored('soundOn')   // what the last run saved; true on the first launch

UIScreen(
  UIButton(UIText(() => (soundOn.value ? 'Sound: on' : 'Sound: off')).style({ color: 'white' }))
    .style({ bgColor: '#FF4032', borderRadius: 12, p: 16 })
    .onClick(() => { soundOn.value = !soundOn.value }),   // saved as it is written
).style({ bgColor: 'black', p: 20 }).open()
  • It is localStorage underneath: the value is saved as JSON under the same key (localStorage.getItem('soundOn') reads "false"), so give each signal a key of its own.
  • JSON values only: a Date or a class instance does not come back as one.
  • Write a new array or object (list.value = [ ...list.value, item ]): one changed in place is the same value — nothing is told and nothing is saved.

Show onboarding only on the first launch

A flag under a key tells the first launch from the others: getItem returns null until the flag is set, so open the onboarding then, and set the flag when the user is through it.

TypeScript
const home = UIScreen(
  UIText('Home').style({ fontSize: 24, color: 'white' }),
).style({ bgColor: 'black', p: 20 })

const welcome = UIScreen(
  UIText('Welcome!').style({ fontSize: 24, color: 'white' }),
  UIButton(UIText('Get started').style({ color: 'white' }))
    .style({ bgColor: '#FF4032', borderRadius: 12, p: 16 })
    .onClick(() => {
      localStorage.setItem('onboarded', '1')             // from now on the app opens on home
      Router.replace(home)
    }),
).style({ bgColor: 'black', p: 20, gap: 16 })

Router.init(localStorage.getItem('onboarded') ? home : welcome)   // null = the first launch

When it is written

Reads and writes are synchronous: getItem right after setItem returns the new value. The device keeps the whole store as one document, rewritten after a change — at the next frame (on the web, as soon as the running code finishes) — so a burst of setItem calls is one write. Keep it small: each save writes all of it, and on the web a store over the browser's quota is not saved, with no error. A document or a long list belongs in files or the database.

Save a value when it changes, not in a pause handler (app lifecycle): on iOS and Android the frames stop right after pause, so a value set there is written only when the app comes back — and is lost if the OS closes the app in the background.

Where it is kept: a plain file, not for secrets

The store is a plain JSON file — in the app's own folder (its sandbox) on iOS and Android, under the user's data folder on desktop, in the browser's localStorage on the web — with no secure storage (Keychain / Keystore) for passwords, tokens or keys. Nothing syncs it: another device or another user does not see what this one keeps.

Note

a test run (lecodes render / lecodes test, headless or on the desktop host) keeps the store in memory for that run only — every run starts empty.

Note

projects opened in the le.codes viewer app from a link or a QR code share one store on that device — a key another project wrote can already be there. An app built on its own has its own store.

Pitfalls

TypeScript
localStorage['theme'] = 'dark'       // the browser habit works: the same store as setItem
localStorage.theme                   // 'dark' — undefined for a key never set (getItem gives null)
localStorage['count'] = 5            // stored as the string "5", as in the browser

See also

  • Signals — signal(x).stored(key) and the rest of the reactive state
  • Files & share — files for documents and binary files (desktop, iOS)
  • Database — data shared between devices and users (server code)
  • App lifecycle — the pause / resume events