Skip to main content
All Examples

LiveStore

A LiveStore-backed task list persisted in OPFS that stays reactive across browser tabs. Commands commit events, materializers project them into SQLite, and one Subscription feeds the live query into the Foldkit Model.

Storage
Subscriptions
Commands
Third-Party Library
/
import clsx from 'clsx'
import {
  Array,
  Clock,
  Crypto,
  Effect,
  Match,
  Option,
  Schema,
  Stream,
  String,
} from 'effect'
import { AsyncData, Command, Runtime, Subscription, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { BrowserCrypto } from '@effect/platform-browser'
import { Button, Checkbox, Input } from '@foldkit/ui'

import { Item, Items, events, tables } from './schema'
import { ItemsStore, commitItemEvent } from './store'

export { Item }

// MODEL

const ItemsAsyncData = AsyncData.Schema(Items, Schema.String)

const Filter = Schema.Literals(['All', 'Active', 'Completed'])
type Filter = typeof Filter.Type

export const Model = Schema.Struct({
  itemsAsyncData: ItemsAsyncData.schema,
  maybeMutationError: Schema.Option(Schema.String),
  newItemText: Schema.String,
  filter: Filter,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  UpdatedNewItemText: { text: Schema.String },
  SubmittedNewItem: {},
  SelectedFilter: { filter: Filter },
  ClickedToggleItem: { id: Schema.String },
  ClickedDeleteItem: { id: Schema.String },
  ClickedClearCompleted: {},
  CompletedAddItem: {},
  FailedAddItem: { error: Schema.String },
  CompletedToggleItem: {},
  FailedToggleItem: { error: Schema.String },
  CompletedDeleteItem: {},
  FailedDeleteItem: { error: Schema.String },
  CompletedClearCompleted: {},
  FailedClearCompleted: { error: Schema.String },
  ReceivedItems: { items: Items },
})
export type Message = typeof Message.Type

// INIT

export const init: Runtime.ApplicationInit<Model, Message> = () => ({
  model: {
    itemsAsyncData: ItemsAsyncData.Loading(),
    maybeMutationError: Option.none(),
    newItemText: '',
    filter: 'All',
  },
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message, ItemsStore>

const clearMutationError = (model: Model): Model =>
  evo(model, {
    maybeMutationError: () => Option.none(),
  })

const recordMutationError = (model: Model, error: string) => ({
  model: evo(model, {
    maybeMutationError: () => Option.some(error),
  }),
})

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    UpdatedNewItemText: ({ text }) => ({
      model: evo(model, {
        newItemText: () => text,
      }),
    }),

    SubmittedNewItem: () => {
      const trimmed = String.trim(model.newItemText)

      if (String.isEmpty(trimmed)) {
        return { model }
      }

      return {
        model: evo(model, {
          newItemText: () => '',
          maybeMutationError: () => Option.none(),
        }),
        commands: [AddItem({ text: trimmed })],
      }
    },

    SelectedFilter: ({ filter }) => ({
      model: evo(model, {
        filter: () => filter,
      }),
    }),

    ClickedToggleItem: ({ id }) => ({
      model: clearMutationError(model),
      commands: [ToggleItem({ id })],
    }),

    ClickedDeleteItem: ({ id }) => ({
      model: clearMutationError(model),
      commands: [DeleteItem({ id })],
    }),

    ClickedClearCompleted: () => ({
      model: clearMutationError(model),
      commands: [ClearCompleted()],
    }),

    CompletedAddItem: () => ({ model }),
    FailedAddItem: ({ error }) => recordMutationError(model, error),
    CompletedToggleItem: () => ({ model }),
    FailedToggleItem: ({ error }) => recordMutationError(model, error),
    CompletedDeleteItem: () => ({ model }),
    FailedDeleteItem: ({ error }) => recordMutationError(model, error),
    CompletedClearCompleted: () => ({ model }),
    FailedClearCompleted: ({ error }) => recordMutationError(model, error),

    ReceivedItems: ({ items }) => ({
      model: evo(model, {
        itemsAsyncData: () => ItemsAsyncData.Success({ data: items }),
      }),
    }),
  })

// COMMAND

const describeError = (error: unknown): string =>
  error instanceof Error ? error.message : 'Something went wrong'

export const AddItem = Command.define('AddItem', {
  args: { text: Schema.String },
  messages: [Message.CompletedAddItem, Message.FailedAddItem],
  execute: ({ text }) =>
    Effect.gen(function* () {
      const crypto = yield* Crypto.Crypto
      const id = yield* crypto.randomUUIDv4
      const createdAt = yield* Clock.currentTimeMillis
      yield* commitItemEvent(
        events.itemAdded({ id, text, completed: false, createdAt }),
      )

      return Message.CompletedAddItem()
    }).pipe(
      Effect.provide(BrowserCrypto.layer),
      Effect.catch(error =>
        Effect.succeed(Message.FailedAddItem({ error: describeError(error) })),
      ),
    ),
})

export const ToggleItem = Command.define('ToggleItem', {
  args: { id: Schema.String },
  messages: [Message.CompletedToggleItem, Message.FailedToggleItem],
  execute: ({ id }) =>
    Effect.gen(function* () {
      yield* commitItemEvent(events.itemToggled({ id }))

      return Message.CompletedToggleItem()
    }).pipe(
      Effect.catch(error =>
        Effect.succeed(
          Message.FailedToggleItem({ error: describeError(error) }),
        ),
      ),
    ),
})

export const DeleteItem = Command.define('DeleteItem', {
  args: { id: Schema.String },
  messages: [Message.CompletedDeleteItem, Message.FailedDeleteItem],
  execute: ({ id }) =>
    Effect.gen(function* () {
      yield* commitItemEvent(events.itemDeleted({ id }))

      return Message.CompletedDeleteItem()
    }).pipe(
      Effect.catch(error =>
        Effect.succeed(
          Message.FailedDeleteItem({ error: describeError(error) }),
        ),
      ),
    ),
})

export const ClearCompleted = Command.define('ClearCompleted', {
  messages: [Message.CompletedClearCompleted, Message.FailedClearCompleted],
  execute: Effect.gen(function* () {
    yield* commitItemEvent(events.completedItemsCleared({}))

    return Message.CompletedClearCompleted()
  }).pipe(
    Effect.catch(error =>
      Effect.succeed(
        Message.FailedClearCompleted({ error: describeError(error) }),
      ),
    ),
  ),
})

// SUBSCRIPTION

const streamItems: Stream.Stream<Message, never, ItemsStore> = Stream.unwrap(
  Effect.gen(function* () {
    const store = yield* ItemsStore

    return store
      .subscribeStream(tables.items.orderBy('createdAt', 'asc'))
      .pipe(Stream.map(items => Message.ReceivedItems({ items })))
  }),
)

export const subscriptions = Subscription.make<Model, Message, ItemsStore>()(
  _entry => ({
    items: Subscription.persistent(streamItems),
  }),
)

// VIEW

const filterItems = (items: Items, filter: Filter): Items =>
  Match.value(filter).pipe(
    Match.when('All', () => items),
    Match.when('Active', () => Array.filter(items, item => !item.completed)),
    Match.when('Completed', () => Array.filter(items, item => item.completed)),
    Match.exhaustive,
  )

const headerView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('mb-6 text-center')],
    [
      h.h1([h.Class('text-3xl font-bold text-gray-800')], ['LiveStore']),
      h.p(
        [h.Class('mt-2 text-sm text-gray-500')],
        [
          'Persisted locally with LiveStore. Open this page in a second tab and watch changes appear in both.',
        ],
      ),
    ],
  )

const newItemFormView = (newItemText: string, h: HtmlBuilder<Message>): Html =>
  h.form(
    [h.Class('mb-6'), h.OnSubmit(Message.SubmittedNewItem())],
    [
      h.div(
        [h.Class('flex gap-3')],
        [
          Input.view(
            {
              id: 'new-item',
              value: newItemText,
              placeholder: 'Add a task...',
              onInput: text => Message.UpdatedNewItemText({ text }),
              toView: attributes =>
                h.input([
                  ...attributes.input,
                  h.AriaLabel('New task'),
                  h.Class(
                    'flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
                  ),
                ]),
            },
            h,
          ),
          Button.view(
            {
              type: 'submit',
              toView: attributes =>
                h.button(
                  [
                    ...attributes.button,
                    h.Class(
                      'px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500',
                    ),
                  ],
                  ['Add'],
                ),
            },
            h,
          ),
        ],
      ),
    ],
  )

const loadingView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('py-8 text-center text-gray-400'), h.Role('status')],
    ['Loading LiveStore...'],
  )

const errorView = (error: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class('bg-red-50 border border-red-200 rounded-lg p-4'),
      h.Role('alert'),
    ],
    [
      h.p(
        [h.Class('text-red-800 font-semibold mb-1')],
        ['Could not load tasks'],
      ),
      h.p([h.Class('text-red-600 text-sm')], [error]),
    ],
  )

const mutationErrorView = (error: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class('bg-red-50 border border-red-200 rounded-lg p-4 mb-4'),
      h.Role('alert'),
    ],
    [
      h.p(
        [h.Class('text-red-800 font-semibold mb-1')],
        ['Could not update LiveStore'],
      ),
      h.p([h.Class('text-red-600 text-sm')], [error]),
    ],
  )

const staleBannerView = (error: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.Class(
        'bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 text-sm text-amber-800',
      ),
      h.Role('alert'),
    ],
    [`Showing the last known tasks. The latest refresh failed: ${error}`],
  )

const checkboxBoxClassName = (isChecked: boolean): string =>
  clsx(
    'flex h-4 w-4 items-center justify-center rounded border transition cursor-pointer',
    isChecked ? 'border-blue-600 bg-blue-600' : 'border-gray-300',
  )

const itemView = (item: Item, h: HtmlBuilder<Message>): Html =>
  h.keyed('li')(
    item.id,
    [h.Class('flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg group')],
    [
      Checkbox.view(
        {
          id: `item-${item.id}`,
          isChecked: item.completed,
          onToggle: () => Message.ClickedToggleItem({ id: item.id }),
          toView: attributes =>
            h.div(
              [h.Class('flex items-center')],
              [
                h.div(
                  [
                    ...attributes.checkbox,
                    h.Class(checkboxBoxClassName(item.completed)),
                  ],
                  item.completed
                    ? [h.span([h.Class('text-white text-xs')], ['✓'])]
                    : [],
                ),
                h.span([...attributes.label, h.AriaLabel(item.text)]),
              ],
            ),
        },
        h,
      ),
      h.span(
        [
          h.Class(
            clsx(
              'flex-1',
              item.completed ? 'line-through text-gray-500' : 'text-gray-900',
            ),
          ),
        ],
        [item.text],
      ),
      Button.view(
        {
          onClick: Message.ClickedDeleteItem({ id: item.id }),
          toView: attributes =>
            h.button(
              [
                ...attributes.button,
                h.AriaLabel(`Delete ${item.text}`),
                h.Class(
                  'px-2 py-1 text-red-600 opacity-0 group-hover:opacity-100 hover:bg-red-100 rounded transition-opacity',
                ),
              ],
              ['×'],
            ),
        },
        h,
      ),
    ],
  )

const emptyView = (filter: Filter, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('text-center text-gray-500 py-8')],
    [
      Match.value(filter).pipe(
        Match.when('All', () => 'No tasks yet. Add one above!'),
        Match.when('Active', () => 'No active tasks'),
        Match.when('Completed', () => 'No completed tasks'),
        Match.exhaustive,
      ),
    ],
  )

const filterButtonView = (
  selectedFilter: Filter,
  filter: Filter,
  h: HtmlBuilder<Message>,
): Html =>
  Button.view(
    {
      onClick: Message.SelectedFilter({ filter }),
      toView: attributes =>
        h.button(
          [
            ...attributes.button,
            h.Class(
              clsx(
                'px-3 py-1 rounded',
                selectedFilter === filter
                  ? 'bg-blue-500 text-white'
                  : 'bg-gray-200 text-gray-700 hover:bg-gray-300',
              ),
            ),
          ],
          [filter],
        ),
    },
    h,
  )

const footerView = (
  filter: Filter,
  activeCount: number,
  completedCount: number,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Class('flex flex-col gap-4')],
    [
      h.div(
        [h.Class('text-sm text-gray-600 text-center'), h.Role('status')],
        [`${activeCount} active, ${completedCount} completed`],
      ),
      h.div(
        [h.Class('flex justify-center gap-2')],
        [
          filterButtonView(filter, 'All', h),
          filterButtonView(filter, 'Active', h),
          filterButtonView(filter, 'Completed', h),
        ],
      ),
      completedCount > 0
        ? h.div(
            [h.Class('flex justify-center')],
            [
              Button.view(
                {
                  onClick: Message.ClickedClearCompleted(),
                  toView: attributes =>
                    h.button(
                      [
                        ...attributes.button,
                        h.Class(
                          'px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200',
                        ),
                      ],
                      [`Clear ${completedCount} completed`],
                    ),
                },
                h,
              ),
            ],
          )
        : h.empty,
    ],
  )

const loadedView = (
  model: Model,
  items: Items,
  h: HtmlBuilder<Message>,
): Html => {
  const visibleItems = filterItems(items, model.filter)
  const activeCount = Array.length(Array.filter(items, item => !item.completed))
  const completedCount = Array.length(items) - activeCount

  return h.div(
    [],
    [
      Option.match(AsyncData.getError(model.itemsAsyncData), {
        onNone: () => h.empty,
        onSome: error => staleBannerView(error, h),
      }),
      Array.match(visibleItems, {
        onEmpty: () => emptyView(model.filter, h),
        onNonEmpty: visibleItems =>
          h.ul(
            [h.Class('space-y-2 mb-6')],
            Array.map(visibleItems, item => itemView(item, h)),
          ),
      }),
      footerView(model.filter, activeCount, completedCount, h),
    ],
  )
}

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const body = h.div(
    [h.Class('min-h-screen bg-gray-100 py-8')],
    [
      h.div(
        [h.Class('max-w-md mx-auto bg-white rounded-xl shadow-lg p-6')],
        [
          headerView(h),
          newItemFormView(model.newItemText, h),
          Option.match(model.maybeMutationError, {
            onNone: () => h.empty,
            onSome: error => mutationErrorView(error, h),
          }),
          AsyncData.matchData(model.itemsAsyncData, {
            onEmpty: () => loadingView(h),
            onFailure: error => errorView(error, h),
            onData: items => loadedView(model, items, h),
          }),
        ],
      ),
    ],
  )

  return { title: 'LiveStore', body }
}