@livestore/livestore 0.4.0-dev.21 → 0.4.0-dev.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/QueryCache.js +1 -1
- package/dist/QueryCache.js.map +1 -1
- package/dist/SqliteDbWrapper.d.ts +5 -5
- package/dist/SqliteDbWrapper.d.ts.map +1 -1
- package/dist/SqliteDbWrapper.js +8 -8
- package/dist/SqliteDbWrapper.js.map +1 -1
- package/dist/SqliteDbWrapper.test.js +2 -2
- package/dist/SqliteDbWrapper.test.js.map +1 -1
- package/dist/effect/LiveStore.d.ts +130 -2
- package/dist/effect/LiveStore.d.ts.map +1 -1
- package/dist/effect/LiveStore.js +185 -6
- package/dist/effect/LiveStore.js.map +1 -1
- package/dist/effect/LiveStore.test.d.ts +2 -0
- package/dist/effect/LiveStore.test.d.ts.map +1 -0
- package/dist/effect/LiveStore.test.js +42 -0
- package/dist/effect/LiveStore.test.js.map +1 -0
- package/dist/effect/mod.d.ts +1 -1
- package/dist/effect/mod.d.ts.map +1 -1
- package/dist/effect/mod.js +3 -1
- package/dist/effect/mod.js.map +1 -1
- package/dist/live-queries/base-class.d.ts +3 -3
- package/dist/live-queries/base-class.d.ts.map +1 -1
- package/dist/live-queries/base-class.js +2 -2
- package/dist/live-queries/base-class.js.map +1 -1
- package/dist/live-queries/client-document-get-query.d.ts +1 -1
- package/dist/live-queries/client-document-get-query.d.ts.map +1 -1
- package/dist/live-queries/client-document-get-query.js +1 -1
- package/dist/live-queries/client-document-get-query.js.map +1 -1
- package/dist/live-queries/computed.d.ts.map +1 -1
- package/dist/live-queries/computed.js +2 -2
- package/dist/live-queries/computed.js.map +1 -1
- package/dist/live-queries/db-query.js +14 -14
- package/dist/live-queries/db-query.js.map +1 -1
- package/dist/live-queries/db-query.test.js +2 -2
- package/dist/live-queries/db-query.test.js.map +1 -1
- package/dist/live-queries/signal.test.js +2 -2
- package/dist/live-queries/signal.test.js.map +1 -1
- package/dist/mod.d.ts +2 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +1 -0
- package/dist/mod.js.map +1 -1
- package/dist/reactive.d.ts +9 -9
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +9 -26
- package/dist/reactive.js.map +1 -1
- package/dist/reactive.test.js +2 -2
- package/dist/reactive.test.js.map +1 -1
- package/dist/store/StoreRegistry.d.ts +215 -0
- package/dist/store/StoreRegistry.d.ts.map +1 -0
- package/dist/store/StoreRegistry.js +267 -0
- package/dist/store/StoreRegistry.js.map +1 -0
- package/dist/store/StoreRegistry.test.d.ts +2 -0
- package/dist/store/StoreRegistry.test.d.ts.map +1 -0
- package/dist/store/StoreRegistry.test.js +381 -0
- package/dist/store/StoreRegistry.test.js.map +1 -0
- package/dist/store/create-store.d.ts +56 -6
- package/dist/store/create-store.d.ts.map +1 -1
- package/dist/store/create-store.js +32 -7
- package/dist/store/create-store.js.map +1 -1
- package/dist/store/devtools.d.ts +1 -1
- package/dist/store/devtools.d.ts.map +1 -1
- package/dist/store/devtools.js +16 -3
- package/dist/store/devtools.js.map +1 -1
- package/dist/store/store-eventstream.test.js +2 -2
- package/dist/store/store-eventstream.test.js.map +1 -1
- package/dist/store/store-types.d.ts +59 -9
- package/dist/store/store-types.d.ts.map +1 -1
- package/dist/store/store-types.js.map +1 -1
- package/dist/store/store-types.test.js +1 -1
- package/dist/store/store-types.test.js.map +1 -1
- package/dist/store/store.d.ts +102 -6
- package/dist/store/store.d.ts.map +1 -1
- package/dist/store/store.js +148 -47
- package/dist/store/store.js.map +1 -1
- package/dist/utils/dev.js.map +1 -1
- package/dist/utils/stack-info.js +2 -2
- package/dist/utils/stack-info.js.map +1 -1
- package/dist/utils/tests/fixture.d.ts +1 -1
- package/dist/utils/tests/fixture.d.ts.map +1 -1
- package/dist/utils/tests/fixture.js.map +1 -1
- package/dist/utils/tests/otel.d.ts.map +1 -1
- package/dist/utils/tests/otel.js +5 -5
- package/dist/utils/tests/otel.js.map +1 -1
- package/package.json +59 -18
- package/src/QueryCache.ts +1 -1
- package/src/SqliteDbWrapper.test.ts +4 -2
- package/src/SqliteDbWrapper.ts +12 -11
- package/src/ambient.d.ts +0 -7
- package/src/effect/LiveStore.test.ts +61 -0
- package/src/effect/LiveStore.ts +381 -8
- package/src/effect/mod.ts +13 -1
- package/src/live-queries/__snapshots__/db-query.test.ts.snap +336 -231
- package/src/live-queries/base-class.ts +7 -6
- package/src/live-queries/client-document-get-query.ts +4 -2
- package/src/live-queries/computed.ts +3 -2
- package/src/live-queries/db-query.test.ts +3 -2
- package/src/live-queries/db-query.ts +15 -15
- package/src/live-queries/signal.test.ts +3 -2
- package/src/mod.ts +2 -0
- package/src/reactive.test.ts +3 -2
- package/src/reactive.ts +22 -23
- package/src/store/StoreRegistry.test.ts +540 -0
- package/src/store/StoreRegistry.ts +418 -0
- package/src/store/create-store.ts +76 -15
- package/src/store/devtools.ts +20 -6
- package/src/store/store-eventstream.test.ts +4 -2
- package/src/store/store-types.test.ts +3 -1
- package/src/store/store-types.ts +64 -13
- package/src/store/store.ts +197 -60
- package/src/utils/dev.ts +2 -2
- package/src/utils/stack-info.ts +2 -2
- package/src/utils/tests/fixture.ts +2 -1
- package/src/utils/tests/otel.ts +8 -7
- package/docs/api/index.md +0 -3
- package/docs/building-with-livestore/complex-ui-state/index.md +0 -5
- package/docs/building-with-livestore/crud/index.md +0 -5
- package/docs/building-with-livestore/data-modeling/index.md +0 -1
- package/docs/building-with-livestore/debugging/index.md +0 -17
- package/docs/building-with-livestore/devtools/index.md +0 -79
- package/docs/building-with-livestore/events/index.md +0 -355
- package/docs/building-with-livestore/examples/ai-agent/index.md +0 -5
- package/docs/building-with-livestore/examples/index.md +0 -30
- package/docs/building-with-livestore/examples/todo-workspaces/index.md +0 -891
- package/docs/building-with-livestore/examples/turnbased-game/index.md +0 -7
- package/docs/building-with-livestore/opentelemetry/index.md +0 -208
- package/docs/building-with-livestore/production-checklist/index.md +0 -5
- package/docs/building-with-livestore/reactivity-system/index.md +0 -202
- package/docs/building-with-livestore/rules-for-ai-agents/index.md +0 -9
- package/docs/building-with-livestore/state/materializers/index.md +0 -300
- package/docs/building-with-livestore/state/sql-queries/index.md +0 -72
- package/docs/building-with-livestore/state/sqlite/index.md +0 -45
- package/docs/building-with-livestore/state/sqlite-schema/index.md +0 -306
- package/docs/building-with-livestore/state/sqlite-schema-effect/index.md +0 -300
- package/docs/building-with-livestore/store/index.md +0 -281
- package/docs/building-with-livestore/syncing/index.md +0 -136
- package/docs/building-with-livestore/tools/cli/index.md +0 -177
- package/docs/building-with-livestore/tools/mcp/index.md +0 -187
- package/docs/examples/cloudflare-adapter/index.md +0 -44
- package/docs/examples/expo-adapter/index.md +0 -44
- package/docs/examples/index.md +0 -55
- package/docs/examples/node-adapter/index.md +0 -44
- package/docs/examples/web-adapter/index.md +0 -52
- package/docs/framework-integrations/custom-elements/index.md +0 -142
- package/docs/framework-integrations/react-integration/index.md +0 -918
- package/docs/framework-integrations/solid-integration/index.md +0 -293
- package/docs/framework-integrations/svelte-integration/index.md +0 -42
- package/docs/framework-integrations/vue-integration/index.md +0 -294
- package/docs/getting-started/expo/index.md +0 -736
- package/docs/getting-started/node/index.md +0 -115
- package/docs/getting-started/react-web/index.md +0 -573
- package/docs/getting-started/solid/index.md +0 -3
- package/docs/getting-started/vue/index.md +0 -471
- package/docs/index.md +0 -209
- package/docs/llms.txt +0 -147
- package/docs/misc/CODE_OF_CONDUCT/index.md +0 -133
- package/docs/misc/FAQ/index.md +0 -37
- package/docs/misc/community/index.md +0 -88
- package/docs/misc/credits/index.md +0 -14
- package/docs/misc/design-partners/index.md +0 -13
- package/docs/misc/package-management/index.md +0 -21
- package/docs/misc/performance/index.md +0 -25
- package/docs/misc/resources/index.md +0 -46
- package/docs/misc/state-of-the-project/index.md +0 -37
- package/docs/misc/troubleshooting/index.md +0 -82
- package/docs/overview/concepts/index.md +0 -78
- package/docs/overview/how-livestore-works/index.md +0 -56
- package/docs/overview/introduction/index.md +0 -5
- package/docs/overview/technology-comparison/index.md +0 -40
- package/docs/overview/when-livestore/index.md +0 -81
- package/docs/overview/why-livestore/index.md +0 -5
- package/docs/patterns/ai/index.md +0 -15
- package/docs/patterns/anonymous-user-transition/index.md +0 -10
- package/docs/patterns/app-evolution/index.md +0 -72
- package/docs/patterns/auth/index.md +0 -226
- package/docs/patterns/effect/index.md +0 -1495
- package/docs/patterns/encryption/index.md +0 -6
- package/docs/patterns/external-data/index.md +0 -5
- package/docs/patterns/file-management/index.md +0 -11
- package/docs/patterns/file-structure/index.md +0 -14
- package/docs/patterns/list-ordering/index.md +0 -369
- package/docs/patterns/offline/index.md +0 -32
- package/docs/patterns/orm/index.md +0 -18
- package/docs/patterns/presence/index.md +0 -11
- package/docs/patterns/rich-text-editing/index.md +0 -11
- package/docs/patterns/server-side-clients/index.md +0 -97
- package/docs/patterns/side-effects/index.md +0 -11
- package/docs/patterns/state-machines/index.md +0 -11
- package/docs/patterns/storybook/index.md +0 -192
- package/docs/patterns/undo-redo/index.md +0 -9
- package/docs/patterns/version-control/index.md +0 -8
- package/docs/platform-adapters/cloudflare-durable-object-adapter/index.md +0 -453
- package/docs/platform-adapters/electron-adapter/index.md +0 -15
- package/docs/platform-adapters/expo-adapter/index.md +0 -245
- package/docs/platform-adapters/node-adapter/index.md +0 -160
- package/docs/platform-adapters/tauri-adapter/index.md +0 -15
- package/docs/platform-adapters/web-adapter/index.md +0 -218
- package/docs/sustainable-open-source/contributing/docs/index.md +0 -94
- package/docs/sustainable-open-source/contributing/info/index.md +0 -63
- package/docs/sustainable-open-source/contributing/monorepo/index.md +0 -195
- package/docs/sustainable-open-source/sponsoring/index.md +0 -104
- package/docs/sync-providers/cloudflare/index.md +0 -773
- package/docs/sync-providers/custom/index.md +0 -65
- package/docs/sync-providers/electricsql/index.md +0 -159
- package/docs/sync-providers/s2/index.md +0 -230
- package/docs/tutorial/0-welcome/index.md +0 -48
- package/docs/tutorial/1-setup-starter-project/index.md +0 -105
- package/docs/tutorial/2-deploy-to-cloudflare/index.md +0 -195
- package/docs/tutorial/3-read-and-write-todos-via-livestore/index.md +0 -511
- package/docs/tutorial/4-sync-data-via-cloudflare/index.md +0 -210
- package/docs/tutorial/5-expand-business-logic/index.md +0 -174
- package/docs/tutorial/6-persist-ui-state/index.md +0 -453
- package/docs/tutorial/7-next-steps/index.md +0 -22
- package/docs/understanding-livestore/design-decisions/index.md +0 -33
- package/docs/understanding-livestore/event-sourcing/index.md +0 -40
|
@@ -1,453 +0,0 @@
|
|
|
1
|
-
# 6. Persist UI state
|
|
2
|
-
|
|
3
|
-
As you saw in the beginning of the tutorial, the state of an application is typically divided into two categories:
|
|
4
|
-
|
|
5
|
-
- **App state**: State that represents the _application data_ a user needs to achieve their goals with your app. In your current case, that's the list of todos. App state _typically_ lives in the Cloud somewhere (in traditional apps, the Cloud is the source of truth for it; in local-first apps, the data is stored locally and backed up in the Cloud).
|
|
6
|
-
- **UI state**: UI state that is only relevant for a particular browser session.
|
|
7
|
-
|
|
8
|
-
Many websites have the problem of "losing UI state" on browser refreshes. This can be incredibly frustrating for users, especially when they've already invested a lot of time getting to a certain point in an app (e.g. filling out a form). Then, the site reloads for some reason and they have to start over!
|
|
9
|
-
|
|
10
|
-
With LiveStore, this problem is easily solved: It allows you to persist _UI state_ (e.g. form inputs, active tabs, custom UI elements, and pretty much anything you'd otherwise manage via `React.useState`). This means users can always pick up exactly where they left off.
|
|
11
|
-
|
|
12
|
-
## Add another UI element
|
|
13
|
-
|
|
14
|
-
First, update `App.tsx` to look as follows:
|
|
15
|
-
|
|
16
|
-
```diff title="src/App.tsx" lang="tsx"
|
|
17
|
-
|
|
18
|
-
function App() {
|
|
19
|
-
|
|
20
|
-
const { store } = useStore()
|
|
21
|
-
|
|
22
|
-
const todos$ = queryDb(() => tables.todos.select())
|
|
23
|
-
const todos = store.useQuery(todos$)
|
|
24
|
-
|
|
25
|
-
const [input, setInput] = useState('')
|
|
26
|
-
+ const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All')
|
|
27
|
-
|
|
28
|
-
const addTodo = () => {
|
|
29
|
-
if (input.trim()) {
|
|
30
|
-
store.commit(
|
|
31
|
-
events.todoCreated({ id: Date.now(), text: input }),
|
|
32
|
-
)
|
|
33
|
-
setInput('')
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const deleteTodo = (id: number) => {
|
|
38
|
-
store.commit(
|
|
39
|
-
events.todoDeleted({ id }),
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const toggleTodo = (id: number, completed: boolean) => {
|
|
44
|
-
store.commit(
|
|
45
|
-
completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
50
|
-
if (e.key === 'Enter') {
|
|
51
|
-
addTodo()
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
|
57
|
-
<div className="w-full max-w-lg">
|
|
58
|
-
<h1 className="text-5xl font-bold text-gray-800 text-center mb-12">
|
|
59
|
-
Todo List
|
|
60
|
-
</h1>
|
|
61
|
-
|
|
62
|
-
<div className="flex gap-3 mb-8">
|
|
63
|
-
<input
|
|
64
|
-
type="text"
|
|
65
|
-
value={input}
|
|
66
|
-
onChange={(e) => setInput(e.target.value)}
|
|
67
|
-
onKeyDown={handleKeyDown}
|
|
68
|
-
placeholder="Enter a todo..."
|
|
69
|
-
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"
|
|
70
|
-
/>
|
|
71
|
-
<button
|
|
72
|
-
onClick={addTodo}
|
|
73
|
-
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"
|
|
74
|
-
>
|
|
75
|
-
Add
|
|
76
|
-
</button>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
+ <div className="flex gap-1 mb-4 border-b border-gray-200 justify-center">
|
|
80
|
-
+ {(['All', 'Active', 'Completed'] as const).map((tab) => (
|
|
81
|
-
+ <button
|
|
82
|
-
+ key={tab}
|
|
83
|
-
+ onClick={() => setFilter(tab)}
|
|
84
|
-
+ className={`px-5 py-2.5 text-sm bg-transparent border-0 -mb-px cursor-pointer outline-none transition-colors ${filter === tab
|
|
85
|
-
+ ? 'text-blue-500 font-semibold'
|
|
86
|
-
+ : 'text-gray-600 hover:text-gray-800'
|
|
87
|
-
+ }`}
|
|
88
|
-
+ >
|
|
89
|
-
+ {tab}
|
|
90
|
-
+ </button>
|
|
91
|
-
+ ))}
|
|
92
|
-
+ </div>
|
|
93
|
-
|
|
94
|
-
<div className="space-y-3">
|
|
95
|
-
{todos.map(todo => (
|
|
96
|
-
<div
|
|
97
|
-
key={todo.id}
|
|
98
|
-
className="flex items-center justify-between bg-white px-4 py-3 rounded shadow-sm"
|
|
99
|
-
>
|
|
100
|
-
<div className="flex items-center gap-3 flex-1">
|
|
101
|
-
<input
|
|
102
|
-
type="checkbox"
|
|
103
|
-
checked={todo.completed}
|
|
104
|
-
onChange={() => toggleTodo(todo.id, todo.completed)}
|
|
105
|
-
className="w-4 h-4 cursor-pointer"
|
|
106
|
-
/>
|
|
107
|
-
<span className={`text-gray-700 ${todo.completed ? 'line-through text-gray-400' : ''}`}>
|
|
108
|
-
{todo.text}
|
|
109
|
-
</span>
|
|
110
|
-
</div>
|
|
111
|
-
<button
|
|
112
|
-
onClick={() => deleteTodo(todo.id)}
|
|
113
|
-
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"
|
|
114
|
-
>
|
|
115
|
-
Delete
|
|
116
|
-
</button>
|
|
117
|
-
</div>
|
|
118
|
-
))}
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
{todos.length === 0 && (
|
|
122
|
-
<p className="text-center text-gray-400 mt-8">
|
|
123
|
-
No todos yet. Add one above!
|
|
124
|
-
</p>
|
|
125
|
-
)}
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export default App
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
If you run the app now, it'll look similar to this:
|
|
135
|
-
|
|
136
|
-

|
|
137
|
-
|
|
138
|
-
You can switch between tabs and see how the tabbed component updates the currently active tab. Just like `input`, this uses local React state:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All')
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
This state is ephemeral, meaning it won't survive page refreshes. In a small app like this tutorial app, this won't really matter—but when you're building a complex UI where users will lose a lot of work when the state suddenly resets, being able to persist this state can be a real life-saver (including things like scroll positions, input forms, and any other relevant state that's important to your users)!
|
|
145
|
-
|
|
146
|
-
With LiveStore, you can persist the state of the currently active tab across browser refreshes. Let's do it!
|
|
147
|
-
|
|
148
|
-
## Update the LiveStore schema with a client document
|
|
149
|
-
|
|
150
|
-
In your `schema.ts` file, add another table definition to the `tables` object. This time, it won't be of type `State.SQLite.table` though, but rather use LiveStore's special [`clientDocument`](/api/livestore/livestore/namespaces/state/namespaces/sqlite/variables/clientdocument/) type for this:
|
|
151
|
-
|
|
152
|
-
```diff title="src/livestore/schema.ts" lang="ts"
|
|
153
|
-
+import { Events, makeSchema, Schema, State, SessionIdSymbol } from '@livestore/livestore'
|
|
154
|
-
|
|
155
|
-
+export const Filter = Schema.Literal('All', 'Active', 'Completed')
|
|
156
|
-
+export type Filter = typeof Filter.Type
|
|
157
|
-
|
|
158
|
-
export const tables = {
|
|
159
|
-
todos: State.SQLite.table({
|
|
160
|
-
name: 'todos',
|
|
161
|
-
columns: {
|
|
162
|
-
id: State.SQLite.integer({ primaryKey: true }),
|
|
163
|
-
text: State.SQLite.text({ default: '' }),
|
|
164
|
-
completed: State.SQLite.boolean({ default: false }),
|
|
165
|
-
},
|
|
166
|
-
}),
|
|
167
|
-
+ uiState: State.SQLite.clientDocument({
|
|
168
|
-
+ name: 'uiState',
|
|
169
|
-
+ schema: Schema.Struct({ input: Schema.String, filter: Filter }),
|
|
170
|
-
+ default: { id: SessionIdSymbol, value: { input: '', filter: 'All' } },
|
|
171
|
-
+ }),
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
On this table, you define:
|
|
176
|
-
|
|
177
|
-
- A name for this client document.
|
|
178
|
-
- The structure of the client document via `schema`; in your case:
|
|
179
|
-
- The current state of the `input` text field for adding a new todo.
|
|
180
|
-
- The `filter` that'll be used to filter the todos according to their `completed` status.
|
|
181
|
-
- Default values for this client document.
|
|
182
|
-
|
|
183
|
-
Unlike with other application state, you don't need to define custom events and materializers. The only thing you need to do is add the following event to your `events` object:
|
|
184
|
-
|
|
185
|
-
```diff title="src/livestore/schema.ts" lang="ts"
|
|
186
|
-
export const events = {
|
|
187
|
-
todoCreated: Events.synced({
|
|
188
|
-
name: 'v1.TodoCreated',
|
|
189
|
-
schema: Schema.Struct({ id: Schema.Number, text: Schema.String }),
|
|
190
|
-
}),
|
|
191
|
-
todoDeleted: Events.synced({
|
|
192
|
-
name: 'v1.TodoDeleted',
|
|
193
|
-
schema: Schema.Struct({ id: Schema.Number }),
|
|
194
|
-
}),
|
|
195
|
-
// Add these two events
|
|
196
|
-
todoCompleted: Events.synced({
|
|
197
|
-
name: 'v1.TodoCompleted',
|
|
198
|
-
schema: Schema.Struct({ id: Schema.Number }),
|
|
199
|
-
}),
|
|
200
|
-
todoUncompleted: Events.synced({
|
|
201
|
-
name: 'v1.TodoUncompleted',
|
|
202
|
-
schema: Schema.Struct({ id: Schema.Number }),
|
|
203
|
-
}),
|
|
204
|
-
+ uiStateSet: tables.uiState.set,
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
Here, both the event definition and materializer are automatically derived from the client document schema, with the materializer implementing upsert semantics.
|
|
209
|
-
|
|
210
|
-
## Implement local state with LiveStore
|
|
211
|
-
|
|
212
|
-
In this step, you need to add the local state for the tabbed UI element to the `App` component. Additionally, you're going to replace the `useState` hook that you currently use for the `input` state with LiveStore's approach as well.
|
|
213
|
-
|
|
214
|
-
Replace the `useState` usage for `input` and `filter` with LiveStore's [`useClientDocument`](/framework-integrations/react-integration#useclientdocument):
|
|
215
|
-
|
|
216
|
-
```diff title="src/App.tsx" lang="tsx"
|
|
217
|
-
+const [{ input, filter }, setUiState, , uiState$] = store.useClientDocument(tables.uiState)
|
|
218
|
-
|
|
219
|
-
+const updatedInput = (input: string) => setUiState((state) => ({ ...state, input }))
|
|
220
|
-
+const updatedFilter = (filter: Filter) => setUiState((state) => ({ ...state, filter }))
|
|
221
|
-
-const [input, setInput] = useState('')
|
|
222
|
-
-const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All')
|
|
223
|
-
-const updatedInput = (input: string) => store.commit(events.uiStateSet({ input }))
|
|
224
|
-
-const updatedFilter = (filter: Filter) => store.commit(events.uiStateSet({ filter }))
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
Don't forget to update the imports to include the `Filter` type and drop `useState`:
|
|
228
|
-
|
|
229
|
-
```diff title="src/App.tsx" lang="tsx"
|
|
230
|
-
-import { useState } from 'react'
|
|
231
|
-
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
`useClientDocument` persists the UI state inside LiveStore while giving you a React-friendly setter function similar to `useState`.
|
|
235
|
-
|
|
236
|
-
Since you've renamed the functions to update the values of `input` and `filter` you need to adjust the parts of the `App` component where they are used:
|
|
237
|
-
|
|
238
|
-
- `setInput` → `updatedInput`
|
|
239
|
-
- `setFilter` → `updatedFilter`
|
|
240
|
-
|
|
241
|
-
You have now recreated the same functionality from before and are able to switch the tabs in the UI element—with one important difference: If you refresh the browser, the UI state will remain the same as before. Your UI state is now persisted and survives page refreshes:
|
|
242
|
-
|
|
243
|
-

|
|
244
|
-
|
|
245
|
-
## Implement filter logic
|
|
246
|
-
|
|
247
|
-
The last step in this tutorial is to actually update the list based on which tab is currently selected.
|
|
248
|
-
|
|
249
|
-
With all your current knowledge, you could think that the implementation would need to look something like this:
|
|
250
|
-
|
|
251
|
-
```tsx title="src/App.tsx"
|
|
252
|
-
const todos$ = queryDb(() => tables.todos.where({
|
|
253
|
-
completed:
|
|
254
|
-
filter === 'Completed' ? true
|
|
255
|
-
: filter === 'Active' ? false
|
|
256
|
-
: undefined // if `undefined` is passed to `where`, no filtering happens
|
|
257
|
-
}))
|
|
258
|
-
const todos = store.useQuery(todos$)
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
If you try that out though, you'll notice that this works _once_ (when your browser loads for the first time). However, when you switch tabs, the list will not actually update.
|
|
262
|
-
|
|
263
|
-
That's because the query isn't updated with the new value `filter` value when it changes. Here's how you need to do it instead:
|
|
264
|
-
|
|
265
|
-
```tsx title="src/App.tsx"
|
|
266
|
-
const todos$ = queryDb((
|
|
267
|
-
(get) => {
|
|
268
|
-
const { filter } = get(uiState$)
|
|
269
|
-
return tables.todos.where({
|
|
270
|
-
completed: filter === 'Completed' ? true
|
|
271
|
-
: filter === 'Active' ? false
|
|
272
|
-
: undefined
|
|
273
|
-
})
|
|
274
|
-
}
|
|
275
|
-
), { label: 'todos' })
|
|
276
|
-
const todos = store.useQuery(todos$)
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
Here, `uiState$` comes from `useClientDocument`, ensuring that the todos query automatically reacts whenever the persisted UI state changes.
|
|
280
|
-
|
|
281
|
-
This is the final code for the `App` component:
|
|
282
|
-
|
|
283
|
-
```tsx title="src/App.tsx"
|
|
284
|
-
|
|
285
|
-
function App() {
|
|
286
|
-
|
|
287
|
-
const { store } = useStore()
|
|
288
|
-
|
|
289
|
-
const [{ input, filter }, setUiState, , uiState$] = store.useClientDocument(tables.uiState)
|
|
290
|
-
|
|
291
|
-
const todos$ = queryDb(
|
|
292
|
-
(get) => {
|
|
293
|
-
const { filter } = get(uiState$)
|
|
294
|
-
return tables.todos.where({
|
|
295
|
-
completed: filter === 'Completed' ? true
|
|
296
|
-
: filter === 'Active' ? false
|
|
297
|
-
: undefined
|
|
298
|
-
})
|
|
299
|
-
},
|
|
300
|
-
{ label: 'todos' },
|
|
301
|
-
)
|
|
302
|
-
const todos = store.useQuery(todos$)
|
|
303
|
-
|
|
304
|
-
const updatedInput = (input: string) => setUiState((state) => ({ ...state, input }))
|
|
305
|
-
const updatedFilter = (filter: Filter) => setUiState((state) => ({ ...state, filter }))
|
|
306
|
-
|
|
307
|
-
const addTodo = () => {
|
|
308
|
-
if (input.trim()) {
|
|
309
|
-
store.commit(
|
|
310
|
-
events.todoCreated({ id: Date.now(), text: input }),
|
|
311
|
-
)
|
|
312
|
-
updatedInput('')
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const deleteTodo = (id: number) => {
|
|
317
|
-
store.commit(
|
|
318
|
-
events.todoDeleted({ id }),
|
|
319
|
-
)
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const toggleTodo = (id: number, completed: boolean) => {
|
|
323
|
-
store.commit(
|
|
324
|
-
completed ? events.todoUncompleted({ id }) : events.todoCompleted({ id })
|
|
325
|
-
)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
329
|
-
if (e.key === 'Enter') {
|
|
330
|
-
addTodo()
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<div className="min-h-screen bg-gray-50 flex items-start justify-center p-6">
|
|
336
|
-
<div className="w-full max-w-lg">
|
|
337
|
-
<h1 className="text-5xl font-bold text-gray-800 text-center mb-12">
|
|
338
|
-
Todo List
|
|
339
|
-
</h1>
|
|
340
|
-
|
|
341
|
-
<div className="flex gap-3 mb-8">
|
|
342
|
-
<input
|
|
343
|
-
type="text"
|
|
344
|
-
value={input}
|
|
345
|
-
onChange={(e) => updatedInput(e.target.value)}
|
|
346
|
-
onKeyDown={handleKeyDown}
|
|
347
|
-
placeholder="Enter a todo..."
|
|
348
|
-
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"
|
|
349
|
-
/>
|
|
350
|
-
<button
|
|
351
|
-
onClick={addTodo}
|
|
352
|
-
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"
|
|
353
|
-
>
|
|
354
|
-
Add
|
|
355
|
-
</button>
|
|
356
|
-
</div>
|
|
357
|
-
|
|
358
|
-
<div className="flex gap-1 mb-4 border-b border-gray-200 justify-center">
|
|
359
|
-
{(['All', 'Active', 'Completed'] as const).map((tab) => (
|
|
360
|
-
<button
|
|
361
|
-
key={tab}
|
|
362
|
-
onClick={() => updatedFilter(tab)}
|
|
363
|
-
className={`px-5 py-2.5 text-sm bg-transparent border-0 -mb-px cursor-pointer outline-none transition-colors ${filter === tab
|
|
364
|
-
? 'text-blue-500 font-semibold'
|
|
365
|
-
: 'text-gray-600 hover:text-gray-800'
|
|
366
|
-
}`}
|
|
367
|
-
>
|
|
368
|
-
{tab}
|
|
369
|
-
</button>
|
|
370
|
-
))}
|
|
371
|
-
</div>
|
|
372
|
-
|
|
373
|
-
<div className="space-y-3 min-h-[200px]">
|
|
374
|
-
{todos.map(todo => (
|
|
375
|
-
<div
|
|
376
|
-
key={todo.id}
|
|
377
|
-
className="flex items-center justify-between bg-white px-4 py-3 rounded shadow-sm"
|
|
378
|
-
>
|
|
379
|
-
<div className="flex items-center gap-3 flex-1">
|
|
380
|
-
<input
|
|
381
|
-
type="checkbox"
|
|
382
|
-
checked={todo.completed}
|
|
383
|
-
onChange={() => toggleTodo(todo.id, todo.completed)}
|
|
384
|
-
className="w-4 h-4 cursor-pointer"
|
|
385
|
-
/>
|
|
386
|
-
<span className={`text-gray-700 ${todo.completed ? 'line-through text-gray-400' : ''}`}>
|
|
387
|
-
{todo.text}
|
|
388
|
-
</span>
|
|
389
|
-
</div>
|
|
390
|
-
<button
|
|
391
|
-
onClick={() => deleteTodo(todo.id)}
|
|
392
|
-
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"
|
|
393
|
-
>
|
|
394
|
-
Delete
|
|
395
|
-
</button>
|
|
396
|
-
</div>
|
|
397
|
-
))}
|
|
398
|
-
</div>
|
|
399
|
-
|
|
400
|
-
{todos.length === 0 && (
|
|
401
|
-
<p className="text-center text-gray-400 mt-8">
|
|
402
|
-
No todos yet. Add one above!
|
|
403
|
-
</p>
|
|
404
|
-
)}
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
export default App
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
## Test the app
|
|
414
|
-
|
|
415
|
-
You can test the app by running the `dev` script:
|
|
416
|
-
|
|
417
|
-
<Tabs syncKey="package-manager">
|
|
418
|
-
|
|
419
|
-
<TabItem label="bun">
|
|
420
|
-
|
|
421
|
-
<Code code={`bun dev`} lang="sh" />
|
|
422
|
-
|
|
423
|
-
</TabItem>
|
|
424
|
-
|
|
425
|
-
<TabItem label="pnpm">
|
|
426
|
-
|
|
427
|
-
<Code code={`pnpm dev`} lang="sh" />
|
|
428
|
-
|
|
429
|
-
</TabItem>
|
|
430
|
-
|
|
431
|
-
</Tabs>
|
|
432
|
-
|
|
433
|
-
The filters now will be applied and update the list of todos when changed:
|
|
434
|
-
|
|
435
|
-

|
|
436
|
-
|
|
437
|
-
You can observe the same behaviour if you deploy the app using the `deploy` script:
|
|
438
|
-
|
|
439
|
-
<Tabs syncKey="package-manager">
|
|
440
|
-
|
|
441
|
-
<TabItem label="bun">
|
|
442
|
-
|
|
443
|
-
<Code code={`bun run deploy`} lang="sh" />
|
|
444
|
-
|
|
445
|
-
</TabItem>
|
|
446
|
-
|
|
447
|
-
<TabItem label="pnpm">
|
|
448
|
-
|
|
449
|
-
<Code code={`pnpm run deploy`} lang="sh" />
|
|
450
|
-
|
|
451
|
-
</TabItem>
|
|
452
|
-
|
|
453
|
-
</Tabs>
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# Next steps
|
|
2
|
-
|
|
3
|
-
This tutorial walked you through the process of building a basic application with LiveStore, explaining its fundamental concepts and workflows by practical example.
|
|
4
|
-
|
|
5
|
-
## Next steps
|
|
6
|
-
|
|
7
|
-
Try one of our starter projects:
|
|
8
|
-
|
|
9
|
-
<LinkButton href="/getting-started/react-web">Quickstart</LinkButton>
|
|
10
|
-
|
|
11
|
-
Follow LiveStore on social media to never miss an update:
|
|
12
|
-
|
|
13
|
-
<LinkButton href="https://x.com/livestoredev">Follow on X</LinkButton>
|
|
14
|
-
<LinkButton href="https://bsky.app/profile/livestoredev.bsky.social">Follow on Bluesky</LinkButton>
|
|
15
|
-
|
|
16
|
-
Join the LiveStore community and chat with other developers:
|
|
17
|
-
|
|
18
|
-
<LinkButton href="https://discord.gg/RbMcjUAPd7/">Join Discord</LinkButton>
|
|
19
|
-
|
|
20
|
-
## Credits
|
|
21
|
-
|
|
22
|
-
This tutorial has been written by [Nikolas Burk](https://x.com/nikolasburk).
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# Design decisions
|
|
2
|
-
|
|
3
|
-
## Goals
|
|
4
|
-
|
|
5
|
-
- Fast, synchronous, transactional, and reactive state management
|
|
6
|
-
- Global state is eventually consistent
|
|
7
|
-
- Persistent storage
|
|
8
|
-
- Syncing
|
|
9
|
-
- Convenient schema migrations
|
|
10
|
-
- Great devtools
|
|
11
|
-
|
|
12
|
-
## Major Design Decisions
|
|
13
|
-
|
|
14
|
-
- Based on [event-sourcing](/understanding-livestore/event-sourcing) (implying a read/write model separation)
|
|
15
|
-
- Using SQLite for state management over JavaScript implementations
|
|
16
|
-
- There are many benefits to using SQLite for state management, including performance, reliability, and ease of use.
|
|
17
|
-
- Run in-memory SQLite in main-thread to enable synchronous queries
|
|
18
|
-
- Usually LiveStore is used with a second SQLite database for persistence running in a separate thread (e.g. web worker)
|
|
19
|
-
- Running SQLite additionally in the main-thread however also means each tab uses extra memory.
|
|
20
|
-
- The current implementation of LiveStore assumes that the data is small enough to fit in memory. However, SQLite is very efficient so this should work for many use cases and apps.
|
|
21
|
-
- LiveStore implements a Signals-based reactivity system based on the ideas of Adapton for incremental computation
|
|
22
|
-
- The goal is to keep LiveStore syncing provider agnostic so you can use the right syncing provider for your use case.
|
|
23
|
-
- LiveStore intentionally stays focused on core data management and syncing, leaving concerns like authentication, file uploads, and business logic to application code. This minimalist approach keeps the library maintainable by limiting surface area, flexible as a composable Unix-like building block, and unopinionated enough to adapt to diverse usage scenarios.
|
|
24
|
-
|
|
25
|
-
## Implementation decisions
|
|
26
|
-
|
|
27
|
-
- Build most of the library in TypeScript. We might move more parts to Rust in the future.
|
|
28
|
-
- Embrace and build on top of [Effect](https://effect.website) as a library of powerful primitives, particularly for IO/concurrency heavy parts of the library.
|
|
29
|
-
|
|
30
|
-
## Original motivation
|
|
31
|
-
|
|
32
|
-
- Frustration with database schema migrations -> event sourcing to separate read and write model (avoid schema migrations for read model)
|
|
33
|
-
- Applying the "Make the right thing easy" principle to app data management
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# Event sourcing
|
|
2
|
-
|
|
3
|
-
- Similar to Redux but persisted and synced across devices
|
|
4
|
-
- Provides a more principled way to handle data instead of relying on mutable state
|
|
5
|
-
- Core idea: Separate read vs write model
|
|
6
|
-
- Read model: App database (i.e. SQLite)
|
|
7
|
-
- Write model: Ordered log of all mutation events
|
|
8
|
-
- Related topics
|
|
9
|
-
- Domain driven design
|
|
10
|
-
- Benefits
|
|
11
|
-
- Simple mental model
|
|
12
|
-
- Preserves user intent
|
|
13
|
-
- Scalable
|
|
14
|
-
- Flexible
|
|
15
|
-
- You can easily evolve the read model based on your query patterns as your app requirements change over time
|
|
16
|
-
- Flexible merge conflicts resolution
|
|
17
|
-
- Automatic migrations of the read model (i.e. app database)
|
|
18
|
-
- Write model can also be evolved (e.g. via versioned mutations and optionally mapping old mutations to new ones)
|
|
19
|
-
- History of all state changes is captured (e.g. for auditing and debugging)
|
|
20
|
-
- Foundation for syncing
|
|
21
|
-
- Downsides
|
|
22
|
-
- Slightly more boilerplate to manually define mutations
|
|
23
|
-
- Need to be careful so eventlog doesn't grow too much
|
|
24
|
-
|
|
25
|
-
## LiveStore as an event-sourcing framework
|
|
26
|
-
|
|
27
|
-
While the benefits of event sourcing are compelling, building a robust system from scratch is complex and time-consuming. Developers often encounter pitfalls related to data consistency, schema migrations, and efficient state reconstruction.
|
|
28
|
-
|
|
29
|
-
LiveStore provides an off-the-shelf event sourcing solution designed for ease of use and correctness. It simplifies development by:
|
|
30
|
-
|
|
31
|
-
- Providing clear APIs for defining mutations (events).
|
|
32
|
-
- Automatically managing the event log persistence and ordering.
|
|
33
|
-
- Efficiently recomputing the state (e.g. SQLite database) from the eventlog via materializers.
|
|
34
|
-
- Handling complexities like automatic data migrations and offering strategies for conflict resolution during synchronization.
|
|
35
|
-
|
|
36
|
-
This allows you to leverage the power of event sourcing without needing to implement the underlying infrastructure and tackle common edge cases yourself.
|
|
37
|
-
|
|
38
|
-
## Further reading
|
|
39
|
-
|
|
40
|
-
- [The Log: What every software engineer should know about real-time data's unifying abstraction](https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying)
|