Skip to content

Commit

Permalink
examples: refactor and finish up the write-patterns example.
Browse files Browse the repository at this point in the history
  • Loading branch information
thruflo committed Dec 5, 2024
1 parent c09ffdb commit 1a62527
Show file tree
Hide file tree
Showing 22 changed files with 597 additions and 525 deletions.
87 changes: 66 additions & 21 deletions examples/write-patterns/README.md
Original file line number Diff line number Diff line change
@@ -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)

<!--
You can see the example deployed and running online at:
https://write-patterns.examples.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 &mdash; and are often perfectly serviceable for most applications.

## How to run

Expand All @@ -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
```
1 change: 1 addition & 0 deletions examples/write-patterns/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion examples/write-patterns/patterns/1-online-writes/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

# Online writes example
# Online writes pattern

This is an example of an application using:

Expand Down
2 changes: 2 additions & 0 deletions examples/write-patterns/patterns/1-online-writes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export default function OnlineWrites() {
return <div className="loading">Loading &hellip;</div>
}

// The template below the heading is identical to the other patterns.

// prettier-ignore
return (
<div id="online-writes" className="example">
Expand Down
6 changes: 4 additions & 2 deletions examples/write-patterns/patterns/2-optimistic-state/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

# Optimistic state example
# Optimistic state pattern

This is an example of an application using:

Expand All @@ -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

Expand Down
48 changes: 18 additions & 30 deletions examples/write-patterns/patterns/2-optimistic-state/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ type Todo = {
completed: boolean
created_at: Date
}
type PartialTodo = Partial<Todo> & {
id: string
}

type OptimisticState = {
type Write = {
operation: 'insert' | 'update' | 'delete'
value: Todo
value: PartialTodo
}

export default function OptimisticState() {
Expand Down Expand Up @@ -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)
}
}
)
Expand Down Expand Up @@ -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(
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -160,7 +148,7 @@ export default function OptimisticState() {
return <div className="loading">Loading &hellip;</div>
}

// 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 (
Expand Down
60 changes: 0 additions & 60 deletions examples/write-patterns/patterns/3-combine-on-read/README.md

This file was deleted.

Loading

0 comments on commit 1a62527

Please sign in to comment.