---
title: "Remember state across visits"
description: "Keep a Firelight component's state across page loads: encode F# records as plain JSON in localStorage, decode them defensively, version the key, and restore after the first render so prerendering still matches."
---

# Remember state across visits

Build a packing list that keeps its items in `localStorage`, so they're still there when the page is reloaded.

- [MDN: Window.localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)

Add an item and tick another, then reload the page. Start again puts the list back as it was.

```fsharp
module Snippets.PackingList

open Fable.Core
open Fable.Core.JsInterop
open Browser
open Firelight
open type Firelight.Lit

type Item = { Text: string; Packed: bool }

let private defaults =
    [ { Text = "Passport"; Packed = false }; { Text = "Charger"; Packed = false } ]

/// Change the version when Item changes shape, so old data is ignored rather than misread.
let private key = "firelight-cookbook-packing-v1"

/// Plain objects in, JSON out: Fable's representation of a record isn't meant for storing.
let save (items: Item list) =
    let json =
        items
        |> List.map (fun i -> {| text = i.Text; packed = i.Packed |})
        |> Array.ofList
        |> JS.JSON.stringify

    // Storage can be full or turned off; the list still works, it just isn't kept.
    try
        localStorage.setItem (key, json)
    with _ ->
        ()

/// The stored list, or None if there's none or it isn't what we wrote.
let load () : Item list option =
    let decode (o: obj) =
        if isNull o then
            None
        else
            match (o?text: obj), (o?packed: obj) with
            | (:? string as text), (:? bool as packed) -> Some { Text = text; Packed = packed }
            | _ -> None

    try
        match localStorage.getItem key with
        | null -> None
        | json ->
            match JS.JSON.parse json with
            | :? (obj array) as stored -> stored |> Array.choose decode |> List.ofArray |> Some
            | _ -> None
    with _ ->
        None

[<AttachMembers>]
type PackingList() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "items", PropertyDeclaration<Item list>(state = true)
            "draft", PropertyDeclaration<string>(state = true)
        ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; justify-items: start; }
        ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.25rem; }
        li { display: flex; gap: 0.5rem; align-items: center; }
        .packed { text-decoration: line-through; color: var(--muted); }
        input, button { font: inherit; }
        input[type="text"] { padding: 0.2rem 0.4rem; }
        button { padding: 0.2rem 0.7rem; }
        """

    member val items = defaults with get, set
    member val draft = "" with get, set

    /// Every change goes through here, so every change is saved.
    member this.Change(items: Item list) =
        this.items <- items
        save items

    // Once the first update is done, so the first render matches the prerendered HTML, and the
    // change starts an update of its own.
    override this.firstUpdated _ =
        promise {
            let! _ = this.updateComplete
            load () |> Option.iter (fun items -> this.items <- items)
        }
        |> Promise.start

    member this.ItemView (index: int) (item: Item) =
        let toggle packed =
            this.Change(this.items |> List.updateAt index { item with Packed = packed })

        let remove () = this.Change(this.items |> List.removeAt index)

        html
            $"""
        <li>
            <label class={if item.Packed then "packed" else ""}>
                <input type="checkbox" .checked={item.Packed} @change={Ev.checked' toggle}> {item.Text}
            </label>
            <button aria-label="Remove {item.Text}" @click={fun _ -> remove ()}>×</button>
        </li>"""

    override this.render() =
        let add =
            Ev.submit (fun e ->
                e.preventDefault ()
                let text = this.draft.Trim()

                if text <> "" then
                    this.Change(this.items @ [ { Text = text; Packed = false } ])
                    this.draft <- ""
            )

        html
            $"""
        <ul>{this.items |> List.mapi this.ItemView}</ul>
        <form @submit={add}>
            <input type="text" aria-label="New item" .value={this.draft} @input={Ev.value (fun t -> this.draft <- t)}>
            <button>Add</button>
        </form>
        <button @click={fun _ -> this.Change defaults}>Start again</button>"""

defineElement<PackingList> "my-packing-list"
```

```html
<my-packing-list></my-packing-list>
```

## How it works

`localStorage` holds strings, so the list goes in as JSON. `save` and `load` are plain functions,
outside the component.

- **Encode on purpose.** `save` maps each `Item` to an anonymous record, which [Fable](https://fable.io/)
  compiles to a plain object, and `JS.JSON.stringify` writes that. Stringifying the F# list would
  work too, but the stored names would be the record's field names, and renaming a field would
  quietly lose everyone's data.
- **Decode into F# values.** `JS.JSON.parse` returns arrays and plain objects, not F# lists and
  records. `unbox<Item list>` on its result compiles, then fails wherever list functions or record
  equality meet it, so `load` builds each `Item` itself.
- **Check what you read.** The stored text may be from an older version of the page, edited by
  hand, or written by another script on the same origin. `load` checks each field's type with `:?`,
  which Fable compiles to a `typeof` test. It skips an item that doesn't fit, and returns `None` if
  the text isn't a JSON array at all.
- **Version the key.** The key ends in `-v1`. When `Item` changes shape, change the key, and old
  data is ignored rather than misread.
- **Save on change.** Every change goes through `Change`, which assigns the new list and saves it.
- **Expect storage to fail.** `setItem` throws when storage is full or turned off, and then the list
  works for this visit only.
- **Restore after the first render.** The [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/) HTML can't know
  what's stored in your browser, so it shows the default list, and the first render in the browser
  must match it. `firstUpdated` waits for that update to finish, then loads. Setting a property
  during `firstUpdated` itself also works, but Lit's development build warns that an update was
  scheduled during one.

For a larger model, a decoding library such as [Thoth.Json](https://thoth-org.github.io/Thoth.Json/)
writes the encoders and decoders with you; the
[Kanban sample](https://github.com/roboz0r/Firelight/blob/main/sample/Kanban/Persistence.fs) stores
its whole board that way.

`localStorage` belongs to the origin, so every page on the site shares it, and other tabs see each
change as a `storage` event on `window`. Listen for it, in `connectedCallback`, if two tabs might
edit the list at once. Don't keep anything secret in it: any script on the origin can read it.

## Related

- [Dark and light themes](https://roboz0r.github.io/Firelight/cookbook/theme-toggle/), which stores a single string the same way.
- [Lifecycle](https://roboz0r.github.io/Firelight/guides/lifecycle/#reach-the-dom-after-it-renders), for `firstUpdated` and
  `updateComplete`.
