English | ä¸ć–‡
A real-time todo application built with Expo React Native, demonstrating TanStack DB with ElectricSQL for seamless data synchronization across devices.
- Real-time Sync: Automatic data synchronization using ElectricSQL's shape-based replication
- Offline-first: Local database with automatic sync when connection is restored
- TanStack DB Collections: Type-safe data management with live queries
- Rich Text Editor: Built with
@10play/tentap-editorfor diary/journal entries - Cross-platform: Works on iOS, Android, and Web
- Expo SDK 54 - React Native development framework
- TanStack DB - Local-first database with collections
- ElectricSQL - Postgres-to-app data sync
- Drizzle ORM - TypeScript ORM for schema definition
- React Navigation - Tab navigation
- FastAPI - Python async web framework
- SQLModel - Pydantic + SQLAlchemy for database models
- PostgreSQL - Primary database
- Docker Compose - PostgreSQL and ElectricSQL services
- ElectricSQL - Real-time data replication service
git clone <repository-url>
cd Expo-TanstackDB-ElectricSQLCopy the environment template and configure:
cp .env.example .envEdit .env with your database configuration:
DB_HOST=localhost
DB_PORT=54321
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=electricStart PostgreSQL and ElectricSQL:
docker compose up -dVerify services are running:
docker compose psIf this is your first time setting up the project, you need to initialize and run database migrations:
cd backend
# Install dependencies
uv sync
# Generate initial migration (creates todos table)
uv run alembic revision --autogenerate -m "Initial migration"
# Apply migration to database
uv run alembic upgrade headNote: The backend automatically runs pending migrations on startup, so you only need to manually run migrations during development.
cd backend
uv sync
uv run python -m app.mainThe backend will run on http://localhost:3001
bun installbunx expo startPress one of the following keys to run on your desired platform:
a- Android emulatori- iOS simulatorw- Web browser
├── backend/ # Python FastAPI backend
│ ├── app/
│ │ ├── alembic_runner.py # Auto-migration on startup
│ │ ├── main.py # FastAPI app & routes
│ │ ├── models.py # SQLModel schemas
│ │ └── db.py # Database connection
│ ├── migrations/ # Alembic database migrations
│ │ ├── versions/ # Migration files (auto-generated)
│ │ ├── env.py # Migration environment (async support)
│ │ └── script.py.mako # Migration template
│ ├── alembic.ini # Alembic config (database URL)
│ └── pyproject.toml # Python dependencies & Alembic config
├── src/
│ ├── app/(tabs)/
│ │ ├── _layout.tsx # Tab navigation layout
│ │ └── todo.tsx # Todo screen with TanStack collection
│ ├── components/ # React Native components
│ │ ├── tentap-editor.tsx
│ │ └── glass-toolbar.tsx
│ ├── db/
│ │ └── schema.ts # Drizzle ORM schema
│ └── utils/
│ └── api-client.ts # API client for backend
├── docker-compose.yml # PostgreSQL + ElectricSQL
├── .env.example # Environment variables template
└── package.json
- Frontend creates/updates/deletes todos via TanStack DB collection
- API Client sends mutations to FastAPI backend
- Backend writes to PostgreSQL and returns transaction ID
- ElectricSQL captures changes via WAL replication
- Shape Stream pushes updates to all connected clients
The todoCollection in src/app/(tabs)/todo.tsx uses:
- Shape URL:
http://${hostname}:3001/api/todosfor live updates - Mutation Handlers: Backend API calls for insert/update/delete
- Timestamp Parser: Converts ISO strings to Date objects
Frontend Schema (src/db/schema.ts):
- Drizzle ORM defines the
todostable schema - Zod validation schemas:
selectTodoSchema,insertTodoSchema,updateTodoSchema
TanStack Collection (src/app/(tabs)/todo.tsx):
todoCollectioncreated withelectricCollectionOptions- Shape sync URL:
http://${hostname}:3001/api/todos - Custom parser for timestamp columns (
timestamptz) - Mutation handlers:
onInsert,onUpdate,onDeletethat call the backend API
API Client (src/utils/api-client.ts):
- Standalone fetch-based client for backend communication
- Methods:
createTodo,updateTodo,deleteTodo - Uses
Constants.linkingUrifor device hostname detection - Returns todo data along with transaction ID (txid)
Python backend serving as API proxy and ElectricSQL shape streamer:
Structure (backend/app/):
main.py- FastAPI app with CORS, routes for CRUD and shape proxy, auto-migration on startupmodels.py- SQLModel definitions (Todo,TodoCreate,TodoUpdate)db.py- Async PostgreSQL connection using asyncpg driveralembic_runner.py- Automatic migration runner for startup
API Endpoints:
POST /api/todos- Create todo, returns{todo, txid}PUT /api/todos/{id}- Update todo, returns{todo, txid}DELETE /api/todos/{id}- Delete todo, returns{success, txid}GET /api/todos- Proxies ElectricSQL shape stream with live updates
Shape Stream Proxy Implementation:
The backend proxy (GET /api/todos) is critical for ElectricSQL integration. It must forward all ElectricSQL protocol parameters:
live,live-sse- Enable live updateshandle,expired-handle- Stream position markersoffset- Stream offsetcursor- Transaction cursorlog,log-mode- Logging modewhere,limit,order-by- Query filterscolumns- Column selection
Key Implementation Details:
-
Stream Management (main.py:200-234):
- Use
client.send(req, stream=True)instead ofasync withcontext manager - Keep stream open until client finishes reading
- Clean up resources in
stream_generator'sfinallyblock - Set timeout to 300 seconds for long-lived connections
- Use
-
Passthrough All Status Codes:
- Return all status codes (including 409 Conflict) to client
- Never throw HTTPException for non-200 responses
- TanStack DB Electric Collection needs 409 to handle expired handles
-
Transaction ID Generation (main.py:72-79):
- Use
pg_current_xact_id()::xid::textNOTtxid_current() - Call inside the same transaction as the mutation
- Strip epoch with
::xid::textto match Electric's stream
- Use
Environment Variables:
DATABASE_URL- PostgreSQL connection stringELECTRIC_URL- ElectricSQL instance (default:http://localhost:3000)RUN_MIGRATIONS_ON_STARTUP- Enable/disable auto-migration (default:true)
Configuration:
- Uses hybrid config:
alembic.inifor database URL +pyproject.tomlfor other settings backend/migrations/env.py- Async migration support with SQLModel metadatabackend/migrations/script.py.mako- Migration template withsqlmodelimport- Auto-formats generated migrations with Black
Migration Workflow:
- Modify
backend/app/models.py(SQLModel schemas) - Run
uv run alembic revision --autogenerate -m "Description" - Review generated file in
backend/migrations/versions/ - Run
uv run alembic upgrade headto apply - Backend auto-runs migrations on startup (can be disabled with env var)
Important Notes:
- Always review auto-generated migrations before applying
- Autogenerate detects: table/col add/drop, nullable changes, indexes, foreign keys
- Autogenerate CANNOT detect: table/col renames (shows as drop+add), anonymous constraints
- Use descriptive migration messages
- Test both upgrade and downgrade
Windows Compatibility:
- Must use
alembic.inifor database URL (Windows encoding issue with pyproject.toml) - Comment out
timezone = UTCin bothalembic.iniandpyproject.toml - Migration template includes
import sqlmodelto avoidNameError
docker-compose.yml defines:
- PostgreSQL 16: Port 54321, WAL level logical for replication
- ElectricSQL: Port 3000, connects to PostgreSQL, insecure mode for dev
The core feature is a rich text editor built with @10play/tentap-editor:
Components:
src/components/tentap-editor.tsx- Main editor wrapper usinguseEditorBridgesrc/components/glass-toolbar.tsx- Glassmorphic toolbar that appears when keyboard is shown- Supports multiple contexts: Main, Heading, Link
- Context-aware button states (active/disabled)
- Uses
expo-glass-effectfor glassmorphism UI - Only renders when keyboard is visible (keyboardHeight > 0)
src/components/toolbar-buttons.ts- Button definitions (MAIN_TOOLBAR_BUTTONS, HEADING_BUTTONS)src/components/toolbar-types.ts- TypeScript types for toolbar system
Toolbar System: Each toolbar button has:
id,label,iconaction(editor, state)- Execute commandgetActive(state)- Check if format is activegetDisabled(state)- Check if command is available
- Tailwind CSS v4 via
tailwindcsspackage - Uniwind - React Native-specific Tailwind (metro.config.js integration)
- HeroUI Native - UI component library (
heroui-native) - Glassmorphism -
expo-glass-effectfor frosted glass UI - Class names work via Uniwind's runtime (see
.vscode/settings.jsonfor configured attributes)
Root layout (src/app/_layout.tsx) wraps app with:
GestureHandlerRootView- For react-native-gesture-handlerHeroUINativeProvider- HeroUI context
Host: localhost
Port: 54321
Database: electric
User: postgres
Password: password
psql -h localhost -p 54321 -U postgres -d electricThis project uses Alembic for database schema management.
After modifying models in backend/app/models.py:
cd backend
# Generate migration based on model changes
uv run alembic revision --autogenerate -m "Description of changes"
# Review the generated migration file in backend/migrations/versions/
# Apply the migration
uv run alembic upgrade head# Check current migration version
uv run alembic current
# View migration history
uv run alembic history
# Rollback one migration
uv run alembic downgrade -1
# Rollback all migrations
uv run alembic downgrade base
# Check for pending migrations (without applying)
uv run alembic checkThe backend automatically runs pending migrations when it starts up. This can be disabled by setting the environment variable:
export RUN_MIGRATIONS_ON_STARTUP=false- Always review auto-generated migrations before applying
- Use descriptive migration messages (e.g., "Add priority field to todos")
- Test both upgrade and downgrade to ensure rollbacks work
- Commit migration files to version control
For detailed documentation, see backend/docs/alembic-pyproject-setup.md
bunx tsc --noEmitbun run lintcd backend
uv run python -m app.main # Start with hot reloaddocker compose down -v # Remove volumes
docker compose up -d # Start fresh409 Conflict on app startup
You may see logs like this when the app starts:
INFO: 192.168.2.188 - "GET /api/todos?...&handle=XXX HTTP/1.1" 409 Conflict
INFO: 192.168.2.188 - "GET /api/todos?...&handle=YYY HTTP/1.1" 200 OK
This is normal behavior! The 409 Conflict is part of ElectricSQL's session recovery mechanism:
- Client attempts to reconnect using a cached handle from the previous session
- ElectricSQL detects the handle is expired and returns 409
- Client automatically creates a new connection with a fresh handle
- All subsequent syncs work normally
No action needed - TanStack DB Electric Collection handles this automatically.
Check Electric is running:
curl http://localhost:3000/api/healthVerify backend is running on port 3001:
curl http://localhost:3001/api/health- Ensure your device/emulator and backend are on the same network
- Update
API_BASE_URLinsrc/utils/api-client.tswith your machine's IP
Error: "Cannot read property 'event' of undefined"
This indicates an issue with the shape stream from the backend. Check:
- Backend is running and not throwing errors
- ElectricSQL service is accessible
- All ElectricSQL protocol parameters are forwarded (see
backend/app/main.py)
Error: "Stream has been closed"
The streaming response was closed prematurely. This happens when:
- The backend's proxy function closes the stream before sending the response
- Connection timeout is too short
Solution: Ensure the backend uses client.send(req, stream=True) and keeps the stream open until the client finishes reading (see backend/app/main.py:200-234).
Autogenerate creates empty migration
- Ensure
migrations/env.pyimports all models fromapp.models - Check that
target_metadata = SQLModel.metadata(notNone) - Verify database connection is correct
Migration apply fails with encoding error (Windows)
- This is expected - see backend/docs/alembic-pyproject-setup.md for Windows-specific setup
ElectricSQL reports table dropped/renamed after migration
docker compose restart electricWant to disable auto-migration on startup
export RUN_MIGRATIONS_ON_STARTUP=falseIf mutations appear to work but data doesn't sync from the backend:
Problem: txid mismatch
- The backend returns a txid that doesn't match the actual transaction in Postgres
- This happens when
pg_current_xact_id()is called outside the mutation transaction
Solution: Ensure get_current_txid() is called inside the same transaction as the mutation (see backend/app/main.py:72-79).
Enable debug logging to diagnose txid issues:
// In browser console
localStorage.debug = 'ts/db:electric'This will show you when txids are requested and when they arrive from the stream.
MIT
- TanStack DB Documentation
- ElectricSQL Documentation
- Expo Documentation
- FastAPI Documentation
- Alembic Tutorial - Complete migration guide
- Alembic Setup Plan - Project-specific setup guide