Skip to content

Commit

Permalink
docs: write patterns guide and example. (#2021)
Browse files Browse the repository at this point in the history
Add Writes guide and write-patterns example.
  • Loading branch information
thruflo authored Dec 9, 2024
1 parent 3ef3ac2 commit 9886b08
Show file tree
Hide file tree
Showing 82 changed files with 5,397 additions and 150 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-snails-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@electric-sql/client": patch
---

Expose `shape.stream` as public readonly property.
5 changes: 5 additions & 0 deletions .changeset/tender-seas-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@electric-sql/react": patch
---

Expose `stream` in the useShape result data. This allows React components to easily access the stream to match on.
22 changes: 10 additions & 12 deletions examples/nextjs-example/app/match-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export async function matchStream<T extends Row>({
timeout?: number
}): Promise<ChangeMessage<T>> {
return new Promise<ChangeMessage<T>>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(`matchStream timed out after ${timeout}ms`)
}, timeout)

const unsubscribe = stream.subscribe((messages) => {
const message = messages.filter(isChangeMessage).find(
(message) =>
Expand All @@ -32,18 +36,12 @@ export async function matchStream<T extends Row>({
message: message,
})
)
if (message) return finish(message)
})

const timeoutId = setTimeout(() => {
console.error(`matchStream timed out after ${timeout}ms`)
reject(`matchStream timed out after ${timeout}ms`)
}, timeout)

function finish(message: ChangeMessage<T>) {
clearTimeout(timeoutId)
unsubscribe()
return resolve(message)
}
if (message) {
clearTimeout(timeoutId)
unsubscribe()
return resolve(message)
}
})
})
}
41 changes: 41 additions & 0 deletions examples/write-patterns/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
parser: '@typescript-eslint/parser',
plugins: ['prettier'],
rules: {
quotes: ['error', 'single'],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
ignorePatterns: [
'**/node_modules/**',
'**/dist/**',
'tsup.config.ts',
'vitest.config.ts',
'.eslintrc.js',
],
}
3 changes: 3 additions & 0 deletions examples/write-patterns/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
node_modules
.env.local
6 changes: 6 additions & 0 deletions examples/write-patterns/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}
71 changes: 71 additions & 0 deletions examples/write-patterns/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@

# Write patterns example

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) 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.

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 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

## 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)

The fourth pattern is in [`./patterns/4-database-sync`](./patterns/4-database-sync).

It extends the concept of shared, persistent optimistic state all the way to a local embedded database. Specifically, it:

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

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.

## How to run

Make sure you've installed all dependencies for the monorepo and built the packages (from the monorepo root directory):

```shell
pnpm install
pnpm run -r build
```

Start the docker containers (in this directory):

```shell
pnpm backend:up
```

Start the dev server:

```shell
pnpm dev
```
16 changes: 16 additions & 0 deletions examples/write-patterns/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Write Patterns - ElectricSQL</title>
<link rel="icon" href="/shared/app/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/shared/app/icons/icon-180.png" sizes="180x180" />
<link rel="mask-icon" href="/shared/app/icons/icon.svg" color="#1c1e20" />
<meta name="theme-color" content="#1c1e20" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/shared/app/main.tsx"></script>
</body>
</html>
59 changes: 59 additions & 0 deletions examples/write-patterns/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@electric-examples/write-patterns",
"private": true,
"version": "0.0.1",
"author": "ElectricSQL",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/electric-sql/electric/issues"
},
"type": "module",
"scripts": {
"backend:up": "PROJECT_NAME=write-patterns pnpm -C ../../ run example-backend:up && pnpm db:migrate",
"backend:down": "PROJECT_NAME=write-patterns pnpm -C ../../ run example-backend:down",
"db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./shared/migrations",
"dev": "concurrently \"vite\" \"node shared/backend/api.js\"",
"build": "vite build",
"format": "eslint . --ext ts,tsx --fix",
"stylecheck": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@electric-sql/client": "workspace:*",
"@electric-sql/experimental": "workspace:*",
"@electric-sql/pglite": "^0.2.14",
"@electric-sql/pglite-react": "^0.2.14",
"@electric-sql/pglite-sync": "^0.2.16",
"@electric-sql/react": "workspace:*",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"pg": "^8.12.0",
"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": {
"@databases/pg-migrations": "^5.0.3",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^8.2.2",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"rollup": "2.79.2",
"typescript": "^5.5.3",
"vite": "^5.3.4",
"vite-plugin-pwa": "^0.21.0"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1"
}
}
32 changes: 32 additions & 0 deletions examples/write-patterns/patterns/1-online-writes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@

# Online writes pattern

This is an example of an application using:

- Electric for read-path sync, to sync data from into a local app
- online writes to write data back into Postgres from the local app

It's the simplest of the [write-patterns](https://electric-sql.com/docs/guides/writes#patterns) introduced in the [Writes](https://electric-sql.com/docs/guides/writes#patterns) guide.

> [!TIP] Other examples
> The [Phoenix LiveView example](../../../phoenix-liveview) also implements this pattern &mdash; using Electric to stream data into the LiveView client and normal Phoenix APIs to handle writes.
## Benefits

It's very simple to implement. It allows you [use your existing API](https://electric-sql.com/blog/2024/11/21/local-first-with-your-existing-api). It allows you to create apps that are fast and available offline for reading data.

Good use-cases include:

- live dashboards, data analytics and data visualisation
- AI applications that generate embeddings in the cloud
- systems where writes require online integration anyway, e.g.: making payments

## Drawbacks

You have the network on the write path — slow, laggy, loading spinners.

Interactive applications won't work offline without implementing [optimistic writes with local optimistic state](../2-optimistic-state).

## How to run

See the [How to run](../../README.md#how-to-run) section in the example README.
109 changes: 109 additions & 0 deletions examples/write-patterns/patterns/1-online-writes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useShape } from '@electric-sql/react'
import api from '../../shared/app/client'

const ELECTRIC_URL = import.meta.env.ELECTRIC_URL || 'http://localhost:3000'

type Todo = {
id: string
title: string
completed: boolean
created_at: Date
}

export default function OnlineWrites() {
// Use Electric's `useShape` hook to sync data from Postgres
// into a React state variable.
const { isLoading, data } = useShape<Todo>({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: 'todos',
},
parser: {
timestamptz: (value: string) => new Date(value),
},
})

const todos = data ? data.sort((a, b) => +a.created_at - +b.created_at) : []

// Handle user input events by making requests to the backend
// API to create, update and delete todos.

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

const path = '/todos'
const data = {
id: uuidv4(),
title: title,
created_at: new Date(),
}

await api.request(path, 'POST', data)

form.reset()
}

async function updateTodo(todo: Todo) {
const path = `/todos/${todo.id}`

const data = {
completed: !todo.completed,
}

await api.request(path, 'PUT', data)
}

async function deleteTodo(event: React.MouseEvent, todo: Todo) {
event.preventDefault()

const path = `/todos/${todo.id}`

await api.request(path, 'DELETE')
}

if (isLoading) {
return <div className="loading">Loading &hellip;</div>
}

// prettier-ignore
return (
<div id="online-writes" className="example">
<h3>1. Online writes</h3>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.completed}
onChange={() => updateTodo(todo)}
/>
<span className={`title ${ todo.completed ? 'completed' : '' }`}>
{ todo.title }
</span>
</label>
<a href="#delete" className="close"
onClick={(event) => deleteTodo(event, todo)}>
&#x2715;</a>
</li>
))}
{todos.length === 0 && (
<li>All done 🎉</li>
)}
</ul>
<form onSubmit={createTodo}>
<input type="text" name="todo"
placeholder="Type here &hellip;"
required
/>
<button type="submit">
Add
</button>
</form>
</div>
)
}
Loading

0 comments on commit 9886b08

Please sign in to comment.