Using TanStack DB & ElectricSQL to build real-time apps with optimistic updates
An Integration Guide to TanStack DB & ElectricSQL: Building Real-Time Apps with Optimistic Updates
Abstract
This document is an in-depth technical guide providing a clear, reusable example of how to combine TanStack DB and ElectricSQL to build modern web applications with real-time data synchronization, secure optimistic updates, and transactional consistency. We will explore the core architecture and best practices through a Q&A application centered around a messages
table.
This guide was written based on the following key dependency versions:
@electric-sql/client
:^1.0.4
@electric-sql/react
:^1.0.4
@tanstack/db
:^0.0.12
@tanstack/react-db
:^0.0.13
@tanstack/db-collections
:^0.0.16
Architecture Overview
Our Q&A application architecture is divided into a client and a server, communicating via API calls and a real-time data stream. The interaction model of the core components is as follows:
graph TB subgraph "Client: App" Component[React Component] --> Action[TanStack DB Action] Action --> |Optimistic Update| Collection[DB Collection: Messages] Collection --> |Shape Request| Auth[Shape Auth Proxy] end subgraph "Server: API & Gatekeeper" Auth --> |Validated Request| ShapeProxy[Shape Proxy Endpoint] ShapeProxy --> Electric[ElectricSQL Service] Action --> |API Call: /questions/send| APIEndpoint[API Endpoint] APIEndpoint --> |DB Transaction| Postgres[PostgreSQL Database] end Postgres --> |Replication| Electric Electric --> |Live Data Stream| Collection style Auth fill:#f9f,stroke:#333,stroke-width:2px style APIEndpoint fill:#ccf,stroke:#333,stroke-width:2px style Action fill:#cfc,stroke:#333,stroke-width:2px
1. Foundation: The Shape Gatekeeper Security Model
To securely sync the database to the client in real-time, we cannot expose it directly. ElectricSQL uses the concept of “Shapes” to define subsets of data that a client can subscribe to. Our architecture adds a “Shape Gatekeeper” layer to validate and authorize these data subscription requests.
1.1. Dual JWT Authentication
Our authentication system employs a dual JWT model for flexible and secure data access control:
- Auth Token: A standard JWT used to verify the user’s identity, issued by the main authentication service.
- Shape Token: A short-lived, special-purpose JWT. Before subscribing to a specific data table (a Shape), the client must exchange its valid Auth Token for a Shape Token from the backend. The claims of this token contain the exact table and query conditions (e.g.,
WHERE conversation_id = 'conv-abc'
) it is allowed to access, thus enabling row-level security.
1.2. Step 1: Shape Authorization Endpoint (/shapes/auth-token
)
The client exchanges tokens by calling the backend’s /shapes/auth-token
endpoint.
1 | // File path: apps/api/src/routes/main.ts |
1.3. Step 2: Secure Shape Proxy Endpoint (/shape
)
After obtaining a short-lived Shape Token
, the client does not request the ElectricSQL service directly. Instead, it requests our custom /shape
proxy endpoint. This endpoint’s responsibility is to validate the Shape Token and securely forward the request to the actual ElectricSQL service.
1 | // File path: apps/api/src/routes/main.ts |
1.4. Client-side Collection Definition
On the client, we create a createMessagesCollection
factory function that encapsulates all the logic for obtaining a Shape Token and configuring ElectricSQL’s data synchronization.
1 | // File path: apps/web/src/hooks/data/collections.ts |
1.5. Deep Dive: The createShapeConfig
Factory for Resilient Connections
The core value of createShapeConfig
is its onError
callback, which provides an elegant, automated connection recovery mechanism for handling expired Shape Tokens. The implementation details are provided in the appendix.
1.6. Appendix: Client-side Helper Function Implementations
To make this document self-contained, simplified versions of key helper functions are provided below.
1 | // File path: apps/web/src/lib/api.ts |
1 | // File path: apps/web/src/stores/shape-token-store.ts |
1 | // File path: apps/web/src/hooks/data/shape-config.ts |
2. Core: Atomic Actions & The txid
Bridge
When a user submits a new question, we need to update the client UI, call the backend API, and ensure eventual data consistency. This is where TanStack DB’s createTransaction
and ElectricSQL’s txid
mechanism come into play.
2.1. Transactional Action (sendQuestionAction
)
We encapsulate the operation of sending a new question into an atomic, asynchronous Action function.
1 | // File path: apps/web/src/hooks/data/actions.ts |
2.1.1. Appendix: Placeholder Object Creation
The createQuestionPlaceholder
and createAnswerPlaceholder
functions used in the optimistic update are simple factories for creating objects that match the local database Message
schema.
1 | // File path: apps/web/src/hooks/data/placeholders.ts |
2.2. Backend API & txid
Generation
The backend API’s core responsibilities are:
- Execute all necessary write operations (inserting the question and the answer placeholder) within a single database transaction.
- Obtain the transaction ID (
txid
) from the database operation. - Immediately return the
txid
to the client. - Asynchronously trigger the subsequent answer generation task.
1 | // File path: apps/api/src/routes/main.ts |
2.2.1. Deep Dive: How the Backend Gets the txid
The txid
is the bridge between the client’s action and the backend’s data synchronization. In our backend’s message.service.ts
, this ID is obtained via a transaction helper function that wraps all database write operations. Its core relies on a built-in PostgreSQL function, pg_current_xact_id()
.
1 | // File path: apps/api/src/services/message.service.ts |
The beauty of this design is that the withTransaction
method encapsulates the entire flow: “start transaction, get ID, execute operations, commit transaction.” Any method needing to write to the database atomically and requiring a txid
for ElectricSQL sync can simply use this helper.
2.3. How awaitTxId
Works
The txid
is the key that bridges the gap between the frontend’s optimistic update and the backend’s true data. The await collection.utils.awaitTxId(txid)
function works as follows:
- Listen to the Replication Stream: It registers a temporary listener with ElectricSQL’s client-side runtime.
- Match the Transaction ID: ElectricSQL receives the data replication stream from PostgreSQL. This stream includes metadata for each transaction, including its
txid
. - Resolve the Promise: When the client-side runtime receives a data change that matches the
txid
it is waiting for, it knows that this specific transaction has been successfully synced from the server to the client’s local database. At this point, theawaitTxId
promise is resolved.
This mechanism elegantly guarantees that when the commit()
function returns, our local database not only contains the optimistic placeholders but that these placeholders have already been overwritten by the persisted, true data from the server.
3. The Payoff: Real-time UI with useLiveQuery
With a secure data channel and reliable data operations in place, we can now reap the benefits in the UI layer. TanStack DB’s useLiveQuery
hook allows us to effortlessly subscribe to changes in a Collection. To keep the example concise, the code below only demonstrates the core data subscription and rendering logic, omitting any handling for initial loading or error states.
1 | // File path: apps/web/src/components/question-answer-list.tsx |
When a user submits a new question, sendQuestionAction
’s transaction.mutate()
immediately inserts the placeholders into the local database. useLiveQuery
detects this change, and the QuestionAnswerList
component re-renders instantly, showing the new question and “Generating answer…”, achieving a perfect optimistic update. When the backend’s answer generation task completes and updates the database, ElectricSQL syncs the change back to the client. useLiveQuery
triggers another re-render, updating the answer placeholder with the real response.
4. End-to-End Flow
Let’s summarize the entire process with a sequence diagram:
sequenceDiagram participant User participant Component as React Component participant Action as sendQuestionAction participant LocalDB as TanStack/Electric DB participant API as Backend API participant Postgres participant Electric as ElectricSQL Replication User->>Component: Clicks "Send" button Component->>Action: sendQuestionAction({ content: 'Hello!' }) Action->>LocalDB: Optimistically insert Question & Answer placeholders Note right of LocalDB: UI instantly shows question and "Generating answer..." Action->>API: POST /questions/send API->>Postgres: BEGIN TRANSACTION API->>Postgres: INSERT question message API->>Postgres: INSERT answer placeholder API->>Postgres: COMMIT Postgres-->>API: Returns txid API-->>Action: Returns { success: true, txid: '...' } Action->>LocalDB: await collection.utils.awaitTxId(txid) Note right of LocalDB: Action pauses, listening for replication Postgres-->>Electric: Replicates committed transaction Electric-->>LocalDB: Pushes changes to client LocalDB-->>Action: awaitTxId promise resolves par Background Answer Generation API->>API: Generate Answer API->>Postgres: UPDATE answer message SET content = '...' end Postgres-->>Electric: Replicates answer Electric-->>LocalDB: Pushes final reply to client Note right of LocalDB: UI automatically updates to show the final reply
With this architecture, we have successfully built a powerful real-time application in a declarative, fault-tolerant, and efficient manner. This pattern elegantly encapsulates the complexities of UI updates, data persistence, and real-time synchronization, allowing developers to focus on implementing business logic.