-
Notifications
You must be signed in to change notification settings - Fork 174
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
docs: write patterns guide and example. (#2021)
Add Writes guide and write-patterns example.
- Loading branch information
Showing
82 changed files
with
5,397 additions
and
150 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dist | ||
node_modules | ||
.env.local |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
{ | ||
"semi": false, | ||
"singleQuote": true, | ||
"tabWidth": 2, | ||
"trailingComma": "es5" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
32
examples/write-patterns/patterns/1-online-writes/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 — 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
109
examples/write-patterns/patterns/1-online-writes/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 …</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)}> | ||
✕</a> | ||
</li> | ||
))} | ||
{todos.length === 0 && ( | ||
<li>All done 🎉</li> | ||
)} | ||
</ul> | ||
<form onSubmit={createTodo}> | ||
<input type="text" name="todo" | ||
placeholder="Type here …" | ||
required | ||
/> | ||
<button type="submit"> | ||
Add | ||
</button> | ||
</form> | ||
</div> | ||
) | ||
} |
Oops, something went wrong.