Skip to content

Commit a03dd11

Browse files
committed
PoC @tanstack/db local-first example using record subscriptions.
There's still many open questions: * What's the future of @tanstack/db? Currently a rapidly moving target. * What's a good way to persist state locally to reduce gap to intitial fetch. * Could Compare-and-Swap (CaS) insert semantics help us.
1 parent e964984 commit a03dd11

20 files changed

+759
-0
lines changed

examples/local-first/.gitignore

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
.vite
15+
16+
# Editor directories and files
17+
.vscode/*
18+
!.vscode/extensions.json
19+
.idea
20+
.DS_Store
21+
*.suo
22+
*.ntvs*
23+
*.njsproj
24+
*.sln
25+
*.sw?

examples/local-first/.prettierrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"plugins": ["prettier-plugin-tailwindcss"]
3+
}

examples/local-first/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Local-first/syncing example using @tanstack/db
2+
3+
This is mostly a PoC. @tanstack/db is still early days plus many unknowns on
4+
our end.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import js from "@eslint/js";
2+
import globals from "globals";
3+
import reactHooks from "eslint-plugin-react-hooks";
4+
import reactRefresh from "eslint-plugin-react-refresh";
5+
import tseslint from "typescript-eslint";
6+
7+
export default tseslint.config(
8+
{
9+
ignores: ["dist/", "node_modules/", "traildepot/"],
10+
},
11+
{
12+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
13+
files: ["**/*.{ts,tsx}"],
14+
languageOptions: {
15+
ecmaVersion: 2020,
16+
globals: globals.browser,
17+
},
18+
plugins: {
19+
"react-hooks": reactHooks,
20+
"react-refresh": reactRefresh,
21+
},
22+
rules: {
23+
...reactHooks.configs.recommended.rules,
24+
"react-refresh/only-export-components": [
25+
"warn",
26+
{ allowConstantExport: true },
27+
],
28+
"@typescript-eslint/no-unused-vars": ["warn"],
29+
},
30+
},
31+
);

examples/local-first/index.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!doctype html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8" />
6+
<link rel="icon" href="data:image/png;base64,iVBORw0KGgo=">
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<title>Local-First Example</title>
9+
</head>
10+
11+
<body>
12+
<div id="root"></div>
13+
<script type="module" src="/src/main.tsx"></script>
14+
</body>
15+
16+
</html>

examples/local-first/package.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "local-first",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"build": "tsc -b && vite build",
8+
"check": "tsc --noEmit --skipLibCheck && eslint",
9+
"dev": "vite",
10+
"format": "prettier -w src",
11+
"lint": "eslint .",
12+
"preview": "vite preview"
13+
},
14+
"dependencies": {
15+
"@tanstack/db": "^0.0.12",
16+
"@tanstack/db-collections": "^0.0.15",
17+
"@tanstack/query-core": "^5.81.2",
18+
"@tanstack/react-db": "^0.0.12",
19+
"@tanstack/store": "^0.7.1",
20+
"react": "^19.1.0",
21+
"react-dom": "^19.1.0",
22+
"trailbase": "workspace:*"
23+
},
24+
"devDependencies": {
25+
"@eslint/js": "^9.29.0",
26+
"@types/react": "^19.1.8",
27+
"@types/react-dom": "^19.1.6",
28+
"@vitejs/plugin-react": "^4.5.2",
29+
"eslint": "^9.29.0",
30+
"eslint-plugin-react-hooks": "^5.2.0",
31+
"eslint-plugin-react-refresh": "^0.4.20",
32+
"globals": "^16.2.0",
33+
"prettier": "^3.5.3",
34+
"prettier-plugin-tailwindcss": "^0.6.12",
35+
"typescript": "~5.8.3",
36+
"typescript-eslint": "^8.34.1",
37+
"vite": "^6.3.5"
38+
}
39+
}

examples/local-first/src/App.css

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#root {
2+
max-width: 1280px;
3+
margin: 0 auto;
4+
padding: 2rem;
5+
text-align: center;
6+
}
7+
8+
.logo {
9+
height: 6em;
10+
padding: 1.5em;
11+
will-change: filter;
12+
transition: filter 300ms;
13+
}
14+
.logo:hover {
15+
filter: drop-shadow(0 0 2em #646cffaa);
16+
}
17+
.logo.react:hover {
18+
filter: drop-shadow(0 0 2em #61dafbaa);
19+
}
20+
21+
@keyframes logo-spin {
22+
from {
23+
transform: rotate(0deg);
24+
}
25+
to {
26+
transform: rotate(360deg);
27+
}
28+
}
29+
30+
@media (prefers-reduced-motion: no-preference) {
31+
a:nth-of-type(2) .logo {
32+
animation: logo-spin infinite 20s linear;
33+
}
34+
}
35+
36+
.card {
37+
padding: 2em;
38+
}
39+
40+
.read-the-docs {
41+
color: #888;
42+
}

examples/local-first/src/App.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { QueryClient } from "@tanstack/query-core";
2+
import {
3+
useLiveQuery,
4+
useOptimisticMutation,
5+
createCollection,
6+
} from "@tanstack/react-db";
7+
import { queryCollectionOptions } from "@tanstack/db-collections";
8+
9+
import { Client } from "trailbase";
10+
import { useState } from "react";
11+
import type { FormEvent } from "react";
12+
13+
import { trailBaseCollectionOptions } from "./lib/trailbase.ts";
14+
import "./App.css";
15+
16+
const client = Client.init("http://localhost:4000");
17+
18+
type Data = {
19+
id: number | null;
20+
updated: number | null;
21+
data: string;
22+
};
23+
24+
const queryClient = new QueryClient();
25+
const useTrailBase = true;
26+
27+
const dataCollection = createCollection(
28+
useTrailBase
29+
? trailBaseCollectionOptions<Data>({
30+
client,
31+
recordApi: "data",
32+
getKey: (item) => item.id ?? -1,
33+
})
34+
: queryCollectionOptions<Data>({
35+
id: "data",
36+
queryKey: ["data"],
37+
queryFn: async () =>
38+
(await client.records("data").list<Data>()).records,
39+
getKey: (item) => item.id ?? -1,
40+
queryClient: queryClient,
41+
}),
42+
);
43+
44+
function App() {
45+
const [input, setInput] = useState("");
46+
47+
const { data } = useLiveQuery((q) =>
48+
q
49+
.from({ dataCollection })
50+
.orderBy(`@updated`)
51+
.select(`@id`, `@updated`, `@data`),
52+
);
53+
54+
// Define mutations
55+
const addData = useOptimisticMutation({
56+
mutationFn: async ({ transaction }) => {
57+
const { changes: newData } = transaction.mutations[0];
58+
await client.records("data").create(newData as Data);
59+
60+
await dataCollection.utils.refetch();
61+
},
62+
});
63+
64+
function handleSubmit(e: FormEvent) {
65+
e.preventDefault(); // Don't reload the page.
66+
67+
const form = e.target;
68+
const formData = new FormData(form as HTMLFormElement);
69+
70+
const formJson = Object.fromEntries(formData.entries());
71+
const text = formJson.text as string;
72+
73+
if (text) {
74+
addData.mutate(() => {
75+
dataCollection.insert({
76+
id: null,
77+
updated: null,
78+
data: formJson.text as string,
79+
});
80+
});
81+
82+
console.log(formJson);
83+
}
84+
}
85+
86+
return (
87+
<>
88+
<h1>Local First</h1>
89+
90+
<div className="card">
91+
<table>
92+
<thead>
93+
<tr>
94+
<th>id</th>
95+
<th>updated</th>
96+
<th>data</th>
97+
</tr>
98+
</thead>
99+
100+
<tbody>
101+
{data.map((d, idx) => (
102+
<tr key={`row-${idx}`}>
103+
<td>{d.id}</td>
104+
<td>{d.updated}</td>
105+
<td>{d.data}</td>
106+
</tr>
107+
))}
108+
</tbody>
109+
</table>
110+
</div>
111+
112+
<form method="post" onSubmit={handleSubmit}>
113+
<p className="read-the-docs">
114+
<input
115+
name="text"
116+
type="text"
117+
onInput={(e) => setInput(e.currentTarget.value)}
118+
/>
119+
120+
<button type="submit" disabled={input === ""}>
121+
submit
122+
</button>
123+
</p>
124+
</form>
125+
</>
126+
);
127+
}
128+
129+
export default App;

examples/local-first/src/index.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
:root {
2+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3+
line-height: 1.5;
4+
font-weight: 400;
5+
6+
color-scheme: light dark;
7+
color: rgba(255, 255, 255, 0.87);
8+
background-color: #242424;
9+
10+
font-synthesis: none;
11+
text-rendering: optimizeLegibility;
12+
-webkit-font-smoothing: antialiased;
13+
-moz-osx-font-smoothing: grayscale;
14+
}
15+
16+
a {
17+
font-weight: 500;
18+
color: #646cff;
19+
text-decoration: inherit;
20+
}
21+
a:hover {
22+
color: #535bf2;
23+
}
24+
25+
body {
26+
margin: 0;
27+
display: flex;
28+
place-items: center;
29+
min-width: 320px;
30+
min-height: 100vh;
31+
}
32+
33+
h1 {
34+
font-size: 3.2em;
35+
line-height: 1.1;
36+
}
37+
38+
button {
39+
border-radius: 8px;
40+
border: 1px solid transparent;
41+
padding: 0.6em 1.2em;
42+
font-size: 1em;
43+
font-weight: 500;
44+
font-family: inherit;
45+
background-color: #1a1a1a;
46+
cursor: pointer;
47+
transition: border-color 0.25s;
48+
}
49+
button:hover {
50+
border-color: #646cff;
51+
}
52+
button:focus,
53+
button:focus-visible {
54+
outline: 4px auto -webkit-focus-ring-color;
55+
}
56+
57+
@media (prefers-color-scheme: light) {
58+
:root {
59+
color: #213547;
60+
background-color: #ffffff;
61+
}
62+
a:hover {
63+
color: #747bff;
64+
}
65+
button {
66+
background-color: #f9f9f9;
67+
}
68+
}

0 commit comments

Comments
 (0)