├── .env ├── .gitignore ├── README.md ├── api └── index.ts ├── app.json ├── app ├── _layout.tsx └── index.tsx ├── assets ├── adaptive-icon.png ├── favicon.png ├── icon.png └── splash-icon.png ├── docker-compose.yml ├── drizzle.config.ts ├── index.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── db │ ├── index.ts │ ├── migrate.ts │ ├── migrations │ │ ├── 0000_lush_captain_stacy.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema.ts └── utils │ └── api-client.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | DB_PORT=54321 3 | DB_USER=postgres 4 | DB_PASSWORD=password 5 | DB_NAME=electric 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | .kotlin/ 14 | *.orig.* 15 | *.jks 16 | *.p8 17 | *.p12 18 | *.key 19 | *.mobileprovision 20 | 21 | # Metro 22 | .metro-health-check* 23 | 24 | # debug 25 | npm-debug.* 26 | yarn-debug.* 27 | yarn-error.* 28 | 29 | # macOS 30 | .DS_Store 31 | *.pem 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expo, TanStack DB, and Electric Starter 2 | 3 | https://github.com/user-attachments/assets/b4be50e9-3ab1-4684-8964-26defdfcfeb6 4 | 5 | ## Why these technologies? 6 | 7 | ### TanStack DB 8 | 9 | TanStack DB is a reactive client store for building super-fast apps. It extends TanStack Query with collections, live queries, and optimistic mutations that keep your app reactive, consistent, and blazing fast. It provides a reactive data layer for your application, making it easy to manage and synchronize data between your UI and your database. 10 | 11 | ### ElectricSQL 12 | 13 | ElectricSQL is a Postgres sync engine that solves the hard problems of data synchronization for you, including partial replication, fan-out, and data delivery. It provides a seamless way to sync your Postgres database with your local application, enabling a true local-first experience. This means your app is fast, works offline, and syncs automatically when a connection is available. 14 | 15 | ## Key Files 16 | 17 | * [`app/index.tsx`](./app/index.tsx) - The main entry point for the Expo application. 18 | * [`api/index.ts`](./api/index.ts) - The entry point for your serverless functions (if you choose to use them). 19 | * [`src/db/schema.ts`](./src/db/schema.ts) - The database schema definition for ElectricSQL. 20 | 21 | ## Setup 22 | 23 | 1. Install dependencies: 24 | 25 | ```bash 26 | pnpm i 27 | ``` 28 | 29 | 2. Start the development server & API server in different terminals: 30 | 31 | ```bash 32 | pnpm start 33 | ``` 34 | 35 | ```bash 36 | pnpm api 37 | ``` 38 | 39 | 3. Push database schema changes: 40 | 41 | ```bash 42 | pnpm db:push 43 | ``` 44 | 45 | ## Notes 46 | 47 | * [`react-native-random-uuid`](https://github.com/LinusU/react-native-random-uuid) is needed as a polyfill for TanStack DB on React Native/Expo. 48 | 49 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import { db } from "../src/db"; 4 | import { todos } from "../src/db/schema"; 5 | import { validateInsertTodo, validateUpdateTodo } from "../src/db/schema"; 6 | import { sql, eq } from "drizzle-orm"; 7 | 8 | // Create Express app 9 | const app = express(); 10 | const PORT = process.env.PORT || 3001; 11 | 12 | // Middleware 13 | app.use(cors()); 14 | app.use(express.json()); 15 | 16 | // Health check endpoint 17 | app.get(`/api/health`, (_req, res) => { 18 | res.status(200).json({ status: `ok` }); 19 | }); 20 | 21 | // Generate a transaction ID 22 | async function generateTxId(tx: any): Promise { 23 | // This is specific to postgres and how electricsql works 24 | const [{ txid }] = await tx.execute(sql`SELECT txid_current() as txid`); 25 | return Number(txid); 26 | } 27 | 28 | // ===== TODOS API ===== 29 | 30 | // POST create a new todo 31 | app.post(`/api/todos`, async (req, res) => { 32 | try { 33 | const todoData = validateInsertTodo(req.body); 34 | 35 | const result = await db.transaction(async (tx) => { 36 | const txid = await generateTxId(tx); 37 | const [newTodo] = await tx.insert(todos).values(todoData).returning(); 38 | return { todo: newTodo, txid }; 39 | }); 40 | 41 | res.status(201).json(result); 42 | } catch (error) { 43 | console.error(`Error creating todo:`, error); 44 | res.status(500).json({ 45 | error: `Failed to create todo`, 46 | details: error instanceof Error ? error.message : String(error), 47 | }); 48 | } 49 | }); 50 | 51 | // PUT update a todo 52 | app.put(`/api/todos/:id`, async (req, res) => { 53 | try { 54 | const { id } = req.params; 55 | const todoData = validateUpdateTodo(req.body); 56 | 57 | const result = await db.transaction(async (tx) => { 58 | const txid = await generateTxId(tx); 59 | const [updatedTodo] = await tx 60 | .update(todos) 61 | .set({ ...todoData, updated_at: new Date() }) 62 | .where(eq(todos.id, Number(id))) 63 | .returning(); 64 | 65 | if (!updatedTodo) { 66 | throw new Error(`Todo not found`); 67 | } 68 | return { todo: updatedTodo, txid }; 69 | }); 70 | 71 | res.status(200).json(result); 72 | } catch (error) { 73 | if (error instanceof Error && error.message === `Todo not found`) { 74 | return res.status(404).json({ error: `Todo not found` }); 75 | } 76 | 77 | console.error(`Error updating todo:`, error); 78 | res.status(500).json({ 79 | error: `Failed to update todo`, 80 | details: error instanceof Error ? error.message : String(error), 81 | }); 82 | } 83 | }); 84 | 85 | // DELETE a todo 86 | app.delete(`/api/todos/:id`, async (req, res) => { 87 | try { 88 | const { id } = req.params; 89 | 90 | const result = await db.transaction(async (tx) => { 91 | const txid = await generateTxId(tx); 92 | const [deleted] = await tx 93 | .delete(todos) 94 | .where(eq(todos.id, Number(id))) 95 | .returning({ id: todos.id }); 96 | 97 | if (!deleted) { 98 | throw new Error(`Todo not found`); 99 | } 100 | return { success: true, txid }; 101 | }); 102 | 103 | res.status(200).json(result); 104 | } catch (error) { 105 | if (error instanceof Error && error.message === `Todo not found`) { 106 | return res.status(404).json({ error: `Todo not found` }); 107 | } 108 | 109 | console.error(`Error deleting todo:`, error); 110 | res.status(500).json({ 111 | error: `Failed to delete todo`, 112 | details: error instanceof Error ? error.message : String(error), 113 | }); 114 | } 115 | }); 116 | 117 | // Start server 118 | app.listen(PORT, () => { 119 | console.log(`Server running on port ${PORT}`); 120 | }); 121 | 122 | export default app; 123 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-db-electric-starter", 4 | "slug": "expo-db-electric-starter", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "newArchEnabled": true, 10 | "splash": { 11 | "image": "./assets/splash-icon.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | }, 23 | "edgeToEdgeEnabled": true 24 | }, 25 | "web": { 26 | "favicon": "./assets/favicon.png" 27 | }, 28 | "plugins": [ 29 | "expo-router" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Stack } from "expo-router"; 3 | 4 | /** 5 | * Root layout component that provides the TanStack DB context 6 | * to all routes in the application 7 | */ 8 | export default function RootLayout() { 9 | return ( 10 | 11 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import "react-native-random-uuid"; 2 | import React, { useState } from "react"; 3 | import { 4 | View, 5 | Text, 6 | TextInput, 7 | Button, 8 | FlatList, 9 | StyleSheet, 10 | TouchableOpacity, 11 | } from "react-native"; 12 | import { useLiveQuery } from "@tanstack/react-db"; 13 | import { StatusBar } from "expo-status-bar"; 14 | import { apiClient, hostname } from "../src/utils/api-client"; 15 | import { selectTodoSchema } from "../src/db/schema"; 16 | import { electricCollectionOptions } from "@tanstack/db-collections"; 17 | import { createCollection } from "@tanstack/react-db"; 18 | import { parseISO } from "date-fns"; 19 | 20 | const todoCollection = createCollection( 21 | electricCollectionOptions({ 22 | id: "todos", 23 | schema: selectTodoSchema, 24 | // Electric syncs data using "shapes". These are filtered views 25 | // on database tables that Electric keeps in sync for you. 26 | shapeOptions: { 27 | url: `http://${hostname}:3000/v1/shape`, 28 | params: { 29 | table: "todos", 30 | }, 31 | parser: { 32 | // Parse timestamp columns into JavaScript Date objects 33 | timestamptz: (date: string) => { 34 | return parseISO(date); 35 | }, 36 | }, 37 | }, 38 | onInsert: async ({ transaction }) => { 39 | const { txid } = await apiClient.createTodo( 40 | transaction.mutations[0].modified, 41 | ); 42 | 43 | return { txid: String(txid) }; 44 | }, 45 | onUpdate: async ({ transaction }) => { 46 | const { 47 | original: { id }, 48 | changes, 49 | } = transaction.mutations[0]; 50 | const { txid } = await apiClient.updateTodo(id, changes); 51 | 52 | return { txid: String(txid) }; 53 | }, 54 | onDelete: async ({ transaction }) => { 55 | const { id } = transaction.mutations[0].original; 56 | const { txid } = await apiClient.deleteTodo(id); 57 | 58 | return { txid: String(txid) }; 59 | }, 60 | getKey: (item) => item.id, 61 | }), 62 | ); 63 | 64 | export default function HomeScreen() { 65 | const [newTodoText, setNewTodoText] = useState(""); 66 | 67 | // Query todos from the collection 68 | const { data: todos } = useLiveQuery((q) => q.from({ todoCollection })); 69 | 70 | return ( 71 | 72 | Todo App 73 | 74 | {/* Add new todo */} 75 | 76 | 82 |