Skip to content

5. Expand business logic with more events

In this chapter, you’ll expand the business logic of the app by adding a “todo completed” feature.

You’ll do this in these steps:

  1. Update your schema to:
    1. Add a boolean column to your SQLite table.
    2. Add events to mark a todo as completed and uncompleted.
    3. Add a materializer to update the DB when one of these events happens.
  2. Update the UI with a checkbox element for each todo which sends these events when toggled.

Open your schema.ts file and update it with a new column, and two events (for completing and uncompleting a todo) with two corresponding materializers:

src/livestore/schema.ts
import { Events, makeSchema, Schema, State } from '@livestore/livestore'
export const tables = {
todos: State.SQLite.table({
name: 'todos',
columns: {
id: State.SQLite.integer({ primaryKey: true }),
text: State.SQLite.text({ default: '' }),
completed: State.SQLite.boolean({ default: false }),
},
})
}
export const events = {
todoCreated: Events.synced({
name: 'v1.TodoCreated',
schema: Schema.Struct({ id: Schema.Number, text: Schema.String }),
}),
todoDeleted: Events.synced({
name: 'v1.TodoDeleted',
schema: Schema.Struct({ id: Schema.Number }),
}),
todoCompleted: Events.synced({
name: 'v1.TodoCompleted',
schema: Schema.Struct({ id: Schema.Number }),
}),
todoUncompleted: Events.synced({
name: 'v1.TodoUncompleted',
schema: Schema.Struct({ id: Schema.Number }),
}),
}
const materializers = State.SQLite.materializers(events, {
'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text }),
'v1.TodoDeleted': ({ id }) => tables.todos.delete().where({ id: id }),
'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }),
'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }),
})
const state = State.SQLite.makeState({ tables, materializers })
export const schema = makeSchema({ events, state })

Next, add a toggleTodo function in App.tsx in which you’re emitting the events that you’ve just defined and invoke it from a new checkbox element that’s added to each todo element:

src/App.tsx
import { useState } from 'react'
import { tables, events } from './livestore/schema'
import { useAppStore } from './livestore/store'
import { queryDb } from '@livestore/livestore'
function App() {
const store = useAppStore()
const todos$ = queryDb(() => tables.todos.select())
const todos = store.useQuery(todos$)
const [input, setInput] = useState('')
const addTodo = () => {
if (input.trim()) {
store.commit(
events.todoCreated({ id: Date.now(), text: input }),
)
setInput('')
}
}
const deleteTodo = (id: number) => {
store.commit(
events.todoDeleted({ id }),
)
}
const toggleTodo = (id: number, completed: boolean) => {
store.commit(
completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })
)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
addTodo()
}
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<h1 className="text-5xl font-bold text-gray-800 text-center mb-12">
Todo List
</h1>
<div className="flex gap-3 mb-8">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter a todo..."
className="flex-1 px-4 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={addTodo}
className="px-6 py-2 text-sm font-medium text-white bg-blue-500 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Add
</button>
</div>
<div className="space-y-3">
{todos.map(todo => (
<div
key={todo.id}
className="flex items-center justify-between bg-white px-4 py-3 rounded shadow-sm"
>
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
className="w-4 h-4 cursor-pointer"
/>
<span className={`text-gray-700 ${todo.completed ? 'line-through text-gray-400' : ''}`}>
{todo.text}
</span>
</div>
<button
onClick={() => deleteTodo(todo.id)}
className="px-4 py-1 text-sm font-medium text-white bg-red-500 rounded hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
>
Delete
</button>
</div>
))}
</div>
{todos.length === 0 && (
<p className="text-center text-gray-400 mt-8">
No todos yet. Add one above!
</p>
)}
</div>
</div>
)
}
export default App

If you run or deploy the app, the UI will now look as follows:

Because the completed field is also being persisted in the database, it also auto-syncs across browser sessions and devices.