diff --git a/examples/write-patterns/README.md b/examples/write-patterns/README.md
index c68154e43d..7af71e14fe 100644
--- a/examples/write-patterns/README.md
+++ b/examples/write-patterns/README.md
@@ -1,30 +1,81 @@
+
# Write patterns example
-This example demonstrates the four different [write-patterns](https://electric-sql.com/docs/guides/writes#patterns) described in the [Writes](https://electric-sql.com/docs/guides/writes#patterns) guide.
+This example implements and describes four different patterns for handling writes in an application built with [ElectricSQL](https://electric-sql.com).
+
+These patterns are described in the [Writes guide](https://electric-sql.com/docs/guides/writes#patterns) from the ElectricSQL documentation. It's worth reading the guide for context. The idea is that if you walk through these patterns in turn, you can get a sense of the range of techniques and their evolution in both power and complexity.
-All running together, at the same time, within a single web application.
+The example is set up to run all the patterns together, in the page, at the same time, as components of a single React application. So you can also evaluate their behaviour side-by-side and and with different network connectivity.
-> ... screenshot ...
+[![Screenshot of the application running](./public/screenshot.png)](https://write-patterns.electric-sql.com)
-
+https://write-patterns.electric-sql.com
+
+## Patterns
+
+The main code is in the [`./patterns`](./patterns) folder, which has a subfolder for each pattern. There's also some shared code, including an API server and some app boilerplate in [`./shared`](./shared).
+
+All of the patterns use [Electric](https://electric-sql.com/product/sync) for the read-path (i.e.: syncing data from Postgres into the local app) and implement a different approach to the write-path (i.e.: how they handle local writes and get data from the local app back into Postgres).
+
+### [1. Online writes](./patterns/1-online-writes)
+
+The first pattern is in [`./patterns/1-online-writes`](./patterns/1-online-writes).
+
+This is the simplest approach, which just sends writes to an API and only works if you're online. It has a resilient client that will retry in the event of network failure but the app doesn't update until the write goes through.
+
+### [2. Optimistic state](./patterns/2-optimistic-state)
+
+The second pattern is in [`./patterns/2-optimistic-state`](./patterns/2-optimistic-state).
+
+It extends the first pattern with support for local offline writes with simple optimistic state. The optimistic state is "simple" in the sense that it's only available within the component that makes the write and it's not persisted if the page reloads or the component unmounts.
+
+### [3. Shared persistent optimistic state](./patterns/3-shared-persistent)
+
+The third pattern is in [`./patterns/3-shared-persistent`](./patterns/3-shared-persistent).
+
+It extends the second pattern by storing the optimistic state in a shared, persistent local store. This makes offline writes more resilient and avoids components getting out of sync. It's a compelling point in the design space: providing good UX and DX without introducing too much complexity or any heavy dependencies.
+
+### [4. Through-the-database sync](./patterns/4-database-sync)
-## Source code
+The fourth pattern is in [`./patterns/4-database-sync`](./patterns/4-database-sync).
-There's some shared boilerplate in [`./shared`](./shared). The code implementing the different patterns is in the [`./patterns`](./patterns) folder.
+It extends the concept of shared, persistent optimistic state all the way to a local embedded database. Specifically, it:
-### Patterns
+1. syncs data from Electric into an immutable table
+2. persists local optimistic state in a shadow table
+2. combines the two into a view that provides a unified interface for reads and writes
+4. automatically detects local changes and syncs them to the server
-All of the patterns use [Electric](https://electric-sql.com/product/sync) for the read-path (i.e.: syncing data from Postgres into the local app) and implement a different approach to the write-path (i.e.: how they handle local writes and get data from the local app back into Postgres):
+This provides a pure local-first development experience, where the application code talks directly to a single database "table" and changes sync automatically in the background. However, this "power" does come at the cost of increased complexity in the form of an embedded database, complex local schema and loss of context when handling rollbacks.
-- [`1-online-writes`](./patterns/1-online-writes) works online, writing data through the backend API
-- [`2-optimistic-state`](./patterns/2-optimistic-state) supports offline writes with simple optimistic state (component-scoped, no persistence)
-- [`3-combine-on-read`](./patterns/3-combine-on-read) syncs into an immutable table, persists optimistic state in a shadow table and combines the two on read
-- [`4-through-the-db`](./patterns/4-through-the-db) uses the local database as a unified mutable store, syncs changes to the server and keeps enough history and bookkeeping data around to be able to revert local changes when necessary
+## Complexities
-For more context about the patterns and their benefits and trade-offs, see the [Writes](https://electric-sql.com/docs/guides/writes#patterns) guide.
+There are two key complexities introduced by handling optimistic state:
+
+1. merge logic when receiving synced state from the server
+2. handling rollbacks when writes are rejected
+
+### 1. Merge logic
+
+When a change syncs in over the Electric replication stream, the application has to decide how to handle any overlapping optimistic state. In this example, we implement a blunt strategy of discarding the local state whenever the corresponding row is updated in the synced state.
+
+This approach works and is simple to reason about. However, it won't preserve local changes on top of concurrent changes by other users (or tabs or devices). In this case, you may want to preserve the local state until *your* change syncs through. For example, rebasing the local changes on the updated synced state. For reference, this is implemented in the more realistic [Linearlite example](../linearlite).
+
+### 2. Rollbacks
+
+If an offline write is rejected by the server, the local application needs to find some way to revert the local state and potentially notify the user. This example just clears all local state if any write is rejected. More sophisticated and forgiving strategies are possible, such as:
+
+- marking local writes as rejected and displaying for manual conflict resolution
+- only clearing the set of writes that are causally dependent on the rejected operation
+
+One consideration is the indirection between making a write and handling a rollback. When sending write operations directly to an API, your application code can effect a rollback with the write context still available. When syncing through the database, the original write context is harder to reconstruct.
+
+### YAGNI
+
+Adam Wiggins, one of the authors of the local-first paper, developed Muse, the collaborative whiteboard app, specifically to support concurrent, collaborative editing of an infinite canvas. Having operated at scale with a large user base, one of his main findings [reported back at the first local-first meetup in Berlin in 2023](https://www.youtube.com/watch?v=WEFuEY3fHd0) was that in reality, conflicts are extremely rare and can be mitigated well by strategies like presence.
+
+If you're crafting a highly concurrent, collaborative experience, you may well want to engage with the complexities of sophisticated merge logic and rebasing local state. However, blunt strategies as illustrated in this example can be much easier to implement and reason about — and are often perfectly serviceable for most applications.
## How to run
@@ -46,9 +97,3 @@ Start the dev server:
```shell
pnpm dev
```
-
-When done, tear down the backend containers so you can run other examples:
-
-```shell
-pnpm backend:down
-```
\ No newline at end of file
diff --git a/examples/write-patterns/package.json b/examples/write-patterns/package.json
index 6417d4cdad..fa1108790b 100644
--- a/examples/write-patterns/package.json
+++ b/examples/write-patterns/package.json
@@ -33,6 +33,7 @@
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"uuid": "^10.0.0",
+ "valtio": "^2.1.2",
"zod": "^3.23.8"
},
"devDependencies": {
diff --git a/examples/write-patterns/patterns/1-online-writes/README.md b/examples/write-patterns/patterns/1-online-writes/README.md
index b85e08d29a..d1dd60f04c 100644
--- a/examples/write-patterns/patterns/1-online-writes/README.md
+++ b/examples/write-patterns/patterns/1-online-writes/README.md
@@ -1,5 +1,5 @@
-# Online writes example
+# Online writes pattern
This is an example of an application using:
diff --git a/examples/write-patterns/patterns/1-online-writes/index.tsx b/examples/write-patterns/patterns/1-online-writes/index.tsx
index 10d9b79938..a0658e1295 100644
--- a/examples/write-patterns/patterns/1-online-writes/index.tsx
+++ b/examples/write-patterns/patterns/1-online-writes/index.tsx
@@ -71,6 +71,8 @@ export default function OnlineWrites() {
return
Loading …
}
+ // The template below the heading is identical to the other patterns.
+
// prettier-ignore
return (
diff --git a/examples/write-patterns/patterns/2-optimistic-state/README.md b/examples/write-patterns/patterns/2-optimistic-state/README.md
index 4030a703a0..cd52e3d7ec 100644
--- a/examples/write-patterns/patterns/2-optimistic-state/README.md
+++ b/examples/write-patterns/patterns/2-optimistic-state/README.md
@@ -1,5 +1,5 @@
-# Optimistic state example
+# Optimistic state pattern
This is an example of an application using:
@@ -22,7 +22,9 @@ Good use-cases include:
## Drawbacks
-The local optimistic state is not persistent. Optimistic state is managed within the component. This can become tricky to manage when state is shared across components. More complex apps may benefit from the more comprehensive [combine on read](../../3-combine-on-read) pattern.
+The optimistic state is only available within the component that makes the write. This means that other components rendering the same state may not see it and may display stale data. The optimistic state is also not peristent. So it's lost if you unmount the component or reload the page.
+
+These limitations are addressed by the [shared persistent optimistic state](../../3-shared-persistent) pattern.
## How to run
diff --git a/examples/write-patterns/patterns/2-optimistic-state/index.tsx b/examples/write-patterns/patterns/2-optimistic-state/index.tsx
index 246eba47d3..1c8a3d221e 100644
--- a/examples/write-patterns/patterns/2-optimistic-state/index.tsx
+++ b/examples/write-patterns/patterns/2-optimistic-state/index.tsx
@@ -12,10 +12,13 @@ type Todo = {
completed: boolean
created_at: Date
}
+type PartialTodo = Partial
& {
+ id: string
+}
-type OptimisticState = {
+type Write = {
operation: 'insert' | 'update' | 'delete'
- value: Todo
+ value: PartialTodo
}
export default function OptimisticState() {
@@ -44,20 +47,20 @@ export default function OptimisticState() {
// are being sent-to and syncing-back-from the server.
const [todos, addOptimisticState] = useOptimistic(
sorted,
- (syncedTodos: Todo[], { operation, value }: OptimisticState) => {
+ (synced: Todo[], { operation, value }: Write) => {
switch (operation) {
case 'insert':
- return syncedTodos.some((todo) => todo.id === value.id)
- ? syncedTodos
- : [...syncedTodos, value]
+ return synced.some((todo) => todo.id === value.id)
+ ? synced
+ : [...synced, value as Todo]
case 'update':
- return syncedTodos.map((todo) =>
- todo.id === value.id ? value : todo
+ return synced.map((todo) =>
+ todo.id === value.id ? { ...todo, ...value } : todo
)
case 'delete':
- return syncedTodos.filter((todo) => todo.id !== value.id)
+ return synced.filter((todo) => todo.id !== value.id)
}
}
)
@@ -86,16 +89,11 @@ export default function OptimisticState() {
id: uuidv4(),
title: title,
created_at: new Date(),
+ completed: false,
}
startTransition(async () => {
- addOptimisticState({
- operation: 'insert',
- value: {
- ...data,
- completed: false,
- },
- })
+ addOptimisticState({ operation: 'insert', value: data })
const fetchPromise = api.request(path, 'POST', data)
const syncPromise = matchStream(
@@ -115,17 +113,12 @@ export default function OptimisticState() {
const path = `/todos/${id}`
const data = {
+ id,
completed: !completed,
}
startTransition(async () => {
- addOptimisticState({
- operation: 'update',
- value: {
- ...todo,
- completed: !completed,
- },
- })
+ addOptimisticState({ operation: 'update', value: data })
const fetchPromise = api.request(path, 'PUT', data)
const syncPromise = matchStream(stream, ['update'], matchBy('id', id))
@@ -142,12 +135,7 @@ export default function OptimisticState() {
const path = `/todos/${id}`
startTransition(async () => {
- addOptimisticState({
- operation: 'delete',
- value: {
- ...todo,
- },
- })
+ addOptimisticState({ operation: 'delete', value: { id } })
const fetchPromise = api.request(path, 'DELETE')
const syncPromise = matchStream(stream, ['delete'], matchBy('id', id))
@@ -160,7 +148,7 @@ export default function OptimisticState() {
return Loading …
}
- // The template below the heading is identical to the online example.
+ // The template below the heading is identical to the other patterns.
// prettier-ignore
return (
diff --git a/examples/write-patterns/patterns/3-combine-on-read/README.md b/examples/write-patterns/patterns/3-combine-on-read/README.md
deleted file mode 100644
index ee1d081d48..0000000000
--- a/examples/write-patterns/patterns/3-combine-on-read/README.md
+++ /dev/null
@@ -1,60 +0,0 @@
-
-# Combine on read example
-
-This is an example of an application using:
-
-- Electric for read-path sync
-- local optimistic writes with shared, persistent optimistic state
-
-This pattern can be implemented with a variety of client-side state management and storage mechanisms. For example, we have a [TanStack example](../../../tanstack-example) that uses the TanStack mutation cache for shared optimistic state.
-
-In this implementation, we use Electric together with [PGlite](https://electric-sql.com/product/pglite). Specifically, we:
-
-1. sync data into an immutable table
-2. persist optimistic state in a shadow table
-3. combine the two on read using a view
-
-## Benefits
-
-This is a powerful and pragmatic pattern, occupying a compelling point in the design space. It's relatively simple to implement. Persisting optimistic state makes local writes more resilient.
-
-Storing optimistic state in a shared table allows all your components to see and react to it. This avoids one of the weaknesses with component-scoped optimistic state with a [more naive optimistic state pattern](../2-optimistic-state) and makes this pattern more suitable for more complex, real world apps.
-
-Seperating immutable synced state from mutable local state makes it easy to reason about and implement rollback strategies.
-
-Good use-cases include:
-
-- building local-first software
-- interactive SaaS applications
-- collaboration and authoring software
-
-## Drawbacks
-
-Combining data on-read makes local reads slightly slower.
-
-Using a local embedded database adds a relatively-heavy dependency to your app. This impacts build/bundle size, initialization speed and memory use. The shadow table and trigger machinery complicate your client side schema definition.
-
-Whilst the database is used for local optimistic state, writes are still made via an API. This can often be helpful and pragmatic, allowing you to [re-use your existing API](https://electric-sql.com/blog/2024/11/21/local-first-with-your-existing-api). However, you may want to avoid running an API and leverage [through the DB sync](../../3-through-the-db) for a purer local-first approach.
-
-## Complexities
-
-This implementation simplifies two key complexities:
-
-1. merge logic when receiving synced state from the server
-2. handling rollbacks when writes are rejected
-
-### 1. Merge logic
-
-The entrypoint in the code for merge logic is the very blunt `delete_local_on_synced_trigger` defined in the [`./local-schema.sql`](./local-schema.sql). The current implementation just wipes any local state for a row when any insert, updater or delete to that row syncs in from the server.
-
-This approach works and is simple to reason about. However, it won't preserve local changes on top of concurrent changes by other users (or tabs or devices). More sophisticated implementations could do more sophisticated merge logic here. Such as rebasing the local changes on the new server state. This typically involved maintaining more bookkeeping info and having more complex triggers.
-
-### 2. Rollbacks
-
-The entrypoint for handling rollbacks is handling the fetchPromise return values in the `createTodo`, `updateTodo`, `deleteTodo` event handler functions in [`./index.tsx`](./index.tsx). At the moment, in this implementation, we simply ignore the return value and assume that the write succeeded.
-
-More sophisticated applications could revert the local state for that write if the write is rejected. The benefits of still using HTTP requests to the API for writes instead of syncing [through the DB](../4-through-the-db) is that the write context is still available when handling the rollback.
-
-## How to run
-
-See the [How to run](../../README.md#how-to-run) section in the example README.
diff --git a/examples/write-patterns/patterns/3-combine-on-read/index.tsx b/examples/write-patterns/patterns/3-combine-on-read/index.tsx
deleted file mode 100644
index cee009057b..0000000000
--- a/examples/write-patterns/patterns/3-combine-on-read/index.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import React, { useState } from 'react'
-import { v4 as uuidv4 } from 'uuid'
-
-import {
- PGliteProvider,
- useLiveQuery,
- usePGlite,
-} from '@electric-sql/pglite-react'
-
-import api from '../../shared/app/client'
-import pglite from '../../shared/app/db'
-
-import localSchemaMigrations from './local-schema.sql?raw'
-
-const ELECTRIC_URL = import.meta.env.ELECTRIC_URL || 'http://localhost:3000'
-
-type Todo = {
- id: string
- title: string
- completed: boolean
- created_at: Date
-}
-
-await pglite.exec(localSchemaMigrations)
-
-// This starts the read path sync using Electric.
-await pglite.electric.syncShapeToTable({
- shape: {
- url: `${ELECTRIC_URL}/v1/shape`,
- table: 'todos',
- },
- shapeKey: 'todos',
- table: 'todos_synced',
- primaryKey: ['id'],
-})
-
-export default function Wrapper() {
- return (
-
-
-
- )
-}
-
-function CombineOnRead() {
- const db = usePGlite()
- const results = useLiveQuery('SELECT * FROM todos ORDER BY created_at')
-
- // Allows us to track when writes are being made to the server.
- const [pendingState, setPendingState] = useState([])
- const isPending = pendingState.length === 0 ? false : true
-
- // These are the same event handler functions from the online and
- // optimistic state examples, revised to write local optimistic
- // state to the database.
-
- async function createTodo(event: React.FormEvent) {
- event.preventDefault()
-
- const form = event.target as HTMLFormElement
- const formData = new FormData(form)
- const title = formData.get('todo') as string
-
- form.reset()
-
- const key = Math.random()
- setPendingState((keys) => [...keys, key])
-
- const id = uuidv4()
- const created_at = new Date()
-
- const localWritePromise = db.sql`
- INSERT INTO todos_local (
- id,
- title,
- completed,
- created_at
- )
- VALUES (
- ${id},
- ${title},
- ${false},
- ${created_at}
- )
- `
-
- const path = '/todos'
- const data = {
- id: id,
- title: title,
- created_at: created_at,
- }
- const fetchPromise = api.request(path, 'POST', data)
-
- await Promise.all([localWritePromise, fetchPromise])
-
- setPendingState((keys) => keys.filter((k) => k !== key))
- }
-
- async function updateTodo(todo: Todo) {
- const { id, completed } = todo
-
- const key = Math.random()
- setPendingState((keys) => [...keys, key])
-
- const localWritePromise = db.sql`
- INSERT INTO todos_local (
- id,
- completed
- )
- VALUES (
- ${id},
- ${!completed}
- )
- ON CONFLICT (id)
- DO UPDATE
- SET completed = ${!completed}
- `
-
- const path = `/todos/${id}`
- const data = {
- completed: !completed,
- }
- const fetchPromise = api.request(path, 'PUT', data)
-
- await Promise.all([localWritePromise, fetchPromise])
-
- setPendingState((keys) => keys.filter((k) => k !== key))
- }
-
- async function deleteTodo(event: React.MouseEvent, todo: Todo) {
- event.preventDefault()
-
- const { id } = todo
-
- const key = Math.random()
- setPendingState((keys) => [...keys, key])
-
- const localWritePromise = db.sql`
- INSERT INTO todos_local (
- id,
- deleted
- )
- VALUES (
- ${id},
- ${true}
- )
- ON CONFLICT (id)
- DO UPDATE
- SET deleted = ${true}
- `
-
- const path = `/todos/${id}`
- const fetchPromise = api.request(path, 'DELETE')
-
- await Promise.all([localWritePromise, fetchPromise])
-
- setPendingState((keys) => keys.filter((k) => k !== key))
- }
-
- if (results === undefined) {
- return Loading …
- }
-
- const todos = results.rows
-
- // The template below the heading is identical to the other patterns.
-
- // prettier-ignore
- return (
-
-
-
- 3. Combine on read
-
-
-
-
-
-
- )
-}
diff --git a/examples/write-patterns/patterns/3-combine-on-read/local-schema.sql b/examples/write-patterns/patterns/3-combine-on-read/local-schema.sql
deleted file mode 100644
index 3ed9248dc9..0000000000
--- a/examples/write-patterns/patterns/3-combine-on-read/local-schema.sql
+++ /dev/null
@@ -1,57 +0,0 @@
--- This is the local database schema for PGlite. It mirrors the server schema
--- defined in `../../shared/migrations/01-create-todos.sql` but rather than
--- just defining a single `todos` table to sync into, it defines two tables:
--- `todos_synced` and `todos_local` and a `todos` view to combine on read.
-
--- The `todos_synced` table for immutable, synced state from the server.
-CREATE TABLE IF NOT EXISTS todos_synced (
- id UUID PRIMARY KEY,
- title TEXT NOT NULL,
- completed BOOLEAN NOT NULL,
- created_at TIMESTAMP WITH TIME ZONE NOT NULL
-);
-
--- The `todos_local` table for local optimistic state.
-CREATE TABLE IF NOT EXISTS todos_local (
- id UUID PRIMARY KEY,
- title TEXT,
- completed BOOLEAN,
- created_at TIMESTAMP WITH TIME ZONE,
- -- Track soft deletes
- deleted BOOLEAN DEFAULT FALSE
-);
-
--- The `todos` view to combine the two tables on read.
-CREATE OR REPLACE VIEW todos AS
- SELECT
- COALESCE(local.id, synced.id) AS id,
- CASE WHEN local.title IS NOT NULL
- THEN local.title
- ELSE synced.title
- END AS title,
- CASE WHEN local.completed IS NOT NULL
- THEN local.completed
- ELSE synced.completed
- END AS completed,
- CASE WHEN local.created_at IS NOT NULL
- THEN local.created_at
- ELSE synced.created_at
- END AS created_at
- FROM todos_synced AS synced
- FULL OUTER JOIN todos_local AS local
- ON synced.id = local.id
- WHERE local.id IS NULL OR local.deleted = FALSE;
-
--- Automatically remove local optimistic state.
-CREATE OR REPLACE FUNCTION delete_local_on_sync_trigger()
-RETURNS TRIGGER AS $$
-BEGIN
- DELETE FROM todos_local WHERE id = OLD.id;
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE OR REPLACE TRIGGER delete_local_on_sync
-AFTER INSERT OR UPDATE OR DELETE ON todos_synced
-FOR EACH ROW
-EXECUTE FUNCTION delete_local_on_sync_trigger();
diff --git a/examples/write-patterns/patterns/3-shared-persistent/README.md b/examples/write-patterns/patterns/3-shared-persistent/README.md
new file mode 100644
index 0000000000..0d92845db5
--- /dev/null
+++ b/examples/write-patterns/patterns/3-shared-persistent/README.md
@@ -0,0 +1,37 @@
+
+# Shared persistent optimistic state pattern
+
+This is an example of an application using:
+
+- Electric for read-path sync
+- local optimistic writes with shared, persistent optimistic state
+
+This pattern can be implemented with a variety of client-side state management and storage mechanisms. This example uses [valtio](https://valtio.dev) for a shared reactive store and persists this store to localStorage on any change. This allows us to keep the code very similar to the previous [`../2-optimistic-state`](../2-optimistic-state) pattern (with a valtio `useSnapshot` and a custom reduce function playing almost exactly the same role as the React `useOptimistic` hook).
+
+## Benefits
+
+This is a powerful and pragmatic pattern, occupying a compelling point in the design space. It's relatively simple to implement. Persisting optimistic state makes local writes more resilient.
+
+Storing optimistic state in a shared store allows all your components to see and react to it. This avoids one of the weaknesses with component-scoped optimistic state with a [more naive optimistic state pattern](../2-optimistic-state) and makes this pattern more suitable for more complex, real world apps.
+
+Seperating immutable synced state from mutable local state makes it easy to reason about and implement rollback strategies.
+
+Good use-cases include:
+
+- building local-first software
+- interactive SaaS applications
+- collaboration and authoring software
+
+## Drawbacks
+
+Combining data on-read makes local reads slightly slower. Whilst the database is used for local optimistic state, writes are still made via an API. This can often be helpful and pragmatic, allowing you to [re-use your existing API](https://electric-sql.com/blog/2024/11/21/local-first-with-your-existing-api). However, you may want to avoid running an API and leverage [through the DB sync](../../3-through-the-db) for a purer local-first approach.
+
+## Complexities
+
+This approach works and is simple to reason about. Because it clears local optimistic state only once the specific local write has synced, it does preserve local changes on top of concurrent changes by other users (or tabs or devices).
+
+The entrypoint for handling rollbacks has the local write context as well as the shared store, so it's easy to make rollbacks relatively surgical.
+
+## How to run
+
+See the [How to run](../../README.md#how-to-run) section in the example README.
diff --git a/examples/write-patterns/patterns/3-shared-persistent/index.tsx b/examples/write-patterns/patterns/3-shared-persistent/index.tsx
new file mode 100644
index 0000000000..52d4ee2220
--- /dev/null
+++ b/examples/write-patterns/patterns/3-shared-persistent/index.tsx
@@ -0,0 +1,235 @@
+import React, { useTransition } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+import { subscribe, useSnapshot } from 'valtio'
+import { proxyMap } from 'valtio/utils'
+
+import { type Operation, ShapeStream } from '@electric-sql/client'
+import { matchBy, matchStream } from '@electric-sql/experimental'
+import { useShape } from '@electric-sql/react'
+
+import api from '../../shared/app/client'
+
+const ELECTRIC_URL = import.meta.env.ELECTRIC_URL || 'http://localhost:3000'
+const KEY = 'electric-sql/examples/write-patterns/shared-persistent'
+
+type Todo = {
+ id: string
+ title: string
+ completed: boolean
+ created_at: Date
+}
+type PartialTodo = Partial & {
+ id: string
+}
+
+type Write = {
+ key: string
+ operation: Operation
+ value: PartialTodo
+}
+
+// Define a shared, persistent, reactive store for local optimistic state.
+const optimisticState = proxyMap(
+ JSON.parse(localStorage.getItem(KEY) || '[]')
+)
+subscribe(optimisticState, () => {
+ localStorage.setItem(KEY, JSON.stringify([...optimisticState]))
+})
+
+/*
+ * Add a local write to the optimistic state
+ */
+function addLocalWrite(operation: Operation, value: PartialTodo): Write {
+ const key = uuidv4()
+ const write: Write = {
+ key,
+ operation,
+ value,
+ }
+
+ optimisticState.set(key, write)
+
+ return write
+}
+
+/*
+ * Subscribe to the shape `stream` until the local write syncs back through it.
+ * At which point, delete the local write from the optimistic state.
+ */
+async function matchWrite(stream: ShapeStream, write: Write) {
+ const { key, operation, value } = write
+
+ try {
+ await matchStream(stream, [operation], matchBy('id', value.id))
+ } catch (_err) {
+ return
+ }
+
+ optimisticState.delete(key)
+}
+
+/*
+ * Make an HTTP request to send the write to the API server.
+ * If the request fails, delete the local write from the optimistic state.
+ */
+async function sendRequest(path: string, method: string, write: Write) {
+ const { key, value } = write
+
+ try {
+ await api.request(path, method, value)
+ } catch (_err) {
+ optimisticState.delete(key)
+ }
+}
+
+export default function SharedPersistent() {
+ const [isPending, startTransition] = useTransition()
+
+ // Use Electric's `useShape` hook to sync data from Postgres.
+ const { isLoading, data, stream } = useShape({
+ url: `${ELECTRIC_URL}/v1/shape`,
+ params: {
+ table: 'todos',
+ },
+ parser: {
+ timestamptz: (value: string) => new Date(value),
+ },
+ })
+ const sorted = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []
+
+ // Get the local optimistic state.
+ const writes = useSnapshot