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