Mirror / foldkit-mirror

Add remembered local state with Mirror.kv

For state that should survive reload but does not belong in the URL, use an Effect KeyValueStore. This extends the earlier Model with sidebar and draft, and its initial value with 'open' and '', respectively. The snippets in this section replace the earlier Message and update definitions:

ts
const Model = Schema.Struct({
  filter: Schema.Literals(['all', 'active', 'done']),
  page: Schema.Number,
  sidebar: Schema.Literals(['open', 'closed']),
  draft: Schema.String,
})

Rebuild App from that Model before selecting the added fields:

ts
import { Projection } from 'foldkit-surface'

const Prefs = Mirror.kv(App, {
  key: 'todo/prefs',
  fields: Projection.pick(
    App.model.sidebar,
    App.model.draft,
  ),
})

A key-value mirror restores through a Message. Give it one variant of the application's union, the way a Submodel gets one, and fold the mirror under it:

ts
const Message = defineMessageUnion({
  GotPrefsMessage: { message: Mirror.Message },
  UrlChanged: { url: Url },
})

const foldPrefs = Mirror.fold(Prefs, message => Message.GotPrefsMessage({ message }))

update then matches the whole union, and the mirror's answer arrives inside its variant:

ts
function update(model: Model, message: Message): Return {
  return Message.match(message, {
    GotPrefsMessage: ({ message }) => foldPrefs(model, message),
    UrlChanged: ({ url }) => ({ model: Filters.reduce(model, url) }),
  })
}

Ask the store for its representation at cold load. foldPrefs.init runs the mirror's restore, lifted to yield GotPrefsMessage:

ts
const init = (url: Url): Return =>
  // URL wins first because startup already has it; the store answers later.
  foldPrefs.init(Filters.reduce(initial, url))

The lift is recorded on the Command, so a Story resolves Prefs.restore with the keys the store would answer and sees the wrapped Message reach update.

The shorter path, for an update that only reduces, is to spread Mirror's cases into the union and narrow by tag. It leaves the union with tags the application never matches, so the rest of update is a partial match:

ts
const Message = defineMessageUnion({ ...Mirror.messages, UrlChanged: { url: Url } })

function update(model: Model, message: Message): Return {
  if (Mirror.reduces(message)) return { model: Prefs.reduce(model, message) }
  return Match.value(message).pipe(
    Match.tag('UrlChanged', ({ url }) => ({ model: Filters.reduce(model, url) })),
    Match.orElse(() => ({ model })),
  )
}

const init = (url: Url): Return => ({
  model: Filters.reduce(initial, url),
  commands: [Prefs.restore],
})

And write both representations from the same Model:

ts
const subscriptions = Subscription.make<
  Model,
  Message,
  KeyValueStore.KeyValueStore
>()(() => ({
  ...Filters.subscriptions,
  ...Prefs.subscriptions,
}))

The important part is what did not change: sidebar and draft are still ordinary Model fields. The key-value store is merely how those fields are remembered between sessions.