DraftThis article is currently in draft mode

Building a World State

A world state is a reactive data structure that manages application state. In this article, we'll explore how to build a TodoMVC application using the view model pattern with LiveStore as our world state implementation.

Interactive Demo

Architecture Overview

This example uses React with LiveStore (an event-sourced reactive database) to manage application state. The view model pattern separates business logic from UI components.

Creating the View Model

The view model (VM) defines the shape of our application's state and actions. Here's the TodoMVC VM structure:

import { computed, nanoid, queryDb } from "@livestore/livestore";
import type { Store } from "@livestore/livestore";

import { memoFn } from "@phosphor/utils";
import { emitDebugValue } from "#scripts/lib/dev/emitDebugValue.ts";
import type { LiveQuery } from "./LiveQuery.ts";
import { uiState$ } from "./livestore/queries.js";
import { events, tables } from "./livestore/schema.js";

export type TodoItemVM = {
  key: string;
  text$: LiveQuery<string>;
  completed$: LiveQuery<boolean>;
  toggleCompleted: () => void;
  remove: () => void;
};

export type TodoListVM = {
  header: {
    newTodoText$: LiveQuery<string>; 
    updateNewTodoText: (text: string) => void;
    addTodo: () => void;
  };
  itemList: {
    items$: LiveQuery<TodoItemVM[]>; 
  };
  footer: {
    incompleteCount$: LiveQuery<number>; 
    currentFilter$: LiveQuery<"all" | "active" | "completed">; 
    showAll: () => void;
    showActive: () => void;
    showCompleted: () => void;
    clearCompleted: () => void;
  };
};

const createID = (name: string) => `${name}_${nanoid(12)}`;

export function createTodoListScope(store: Store): TodoListVM {
  const currentFilter$ = computed((query) => query(uiState$).filter, { label: "filter" });
  const newTodoText$ = computed((query) => query(uiState$).newTodoText, { label: "newTodoText" });
  const createTodoItemVM = memoFn((id: string): TodoItemVM => {
    const completed$ = queryDb(() => tables.todos.select("completed").where({ id }).first({ behaviour: "error" }), {
      label: "todoItem.completed",
      deps: [id],
    });
    const text$ = queryDb(() => tables.todos.select("text").where({ id }).first({ behaviour: "error" }), {
      label: "todoItem.text",
      deps: [id],
    });
    return {
      key: id,
      completed$,
      text$,
      toggleCompleted: () =>
        store.commit(store.query(completed$) ? events.todoUncompleted({ id }) : events.todoCompleted({ id })),
      remove: () => store.commit(events.todoDeleted({ id, deletedAt: new Date() })),
    };
  });
  const visibleTodosQuery = (filter: "all" | "active" | "completed") =>
    queryDb(() => tables.todos.where({ completed: filter === "all" ? undefined : filter === "completed" }), {
      label: "visibleTodos",
      map: (rows): TodoItemVM[] => rows.map((row): TodoItemVM => createTodoItemVM(row.id)),
      deps: [filter],
    });
  const visibleTodos$ = computed(
    (query) => {
      const filter = query(currentFilter$);
      return query(visibleTodosQuery(filter));
    },
    { label: "visibleTodos" },
  );

  const incompleteCount$ = queryDb(tables.todos.count().where({ completed: false, deletedAt: null }), {
    label: "incompleteCount",
  });


  return {
    header: {
      newTodoText$: computed((query) => query(newTodoText$), { label: "newTodoText.query" }),
      updateNewTodoText: (text: string) => store.commit(events.uiStateSet({ newTodoText: text })),
      addTodo: () => {
        const text = store.query(newTodoText$);
        if (text.trim()) {
          store.commit(
            events.todoCreated({ id: createID("todo"), text }),
            events.uiStateSet({ newTodoText: "" }), // update text
          );
        }
      },
    },
    itemList: {
      items$: visibleTodos$,
    },
    footer: {
      incompleteCount$,
      currentFilter$,
      showAll: () => store.commit(events.uiStateSet({ filter: "all" })),
      showActive: () => store.commit(events.uiStateSet({ filter: "active" })),
      showCompleted: () => store.commit(events.uiStateSet({ filter: "completed" })),
      clearCompleted: () => store.commit(events.todoClearedCompleted({ deletedAt: new Date() })),
    },
  };
}

Wiring the View Model to React

The Root component sets up the LiveStore provider and creates the view model:

import { LiveStoreProvider, useStore } from "@livestore/react";
import { createContext, useContext, useMemo } from "react";

const TodoVMContext = createContext<TodoListVM | null>(null);

export const useTodoVM = () => {
  const vm = useContext(TodoVMContext);
  if (!vm) throw new Error("useTodoVM must be used within TodoVMProvider");
  return vm;
};

const TodoVMProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const { store } = useStore();
  const vm = useMemo(() => createTodoListScope(store), [store]);
  return <TodoVMContext.Provider value={vm}>{children}</TodoVMContext.Provider>;
};

export const App: React.FC = () => (
  <LiveStoreProvider
    schema={schema}
    adapter={adapter}
    renderLoading={(state) => <div>Loading LiveStore ({state.stage})...</div>}
  >
    <TodoVMProvider>
      <section className="todos">
        <Header />
        <MainSection />
        <Footer />
      </section>
    </TodoVMProvider>
  </LiveStoreProvider>
);

The key concept is that all components share the same view model instance through React Context. The view model is created once when the store is initialized, and components subscribe to reactive queries that automatically re-render when data changes.

Key patterns:

  • Single source of truth: The LiveStore holds all application state
  • Reactive queries: Components use LiveQuery<T> to subscribe to data
  • Action methods: View model exposes methods that commit events to the store
  • Separation of concerns: Business logic lives in the view model, not in components