If you're a frontend developer, you know the feeling. You stand at an architectural crossroads, staring at a map where all roads seem paved with compromise.
One path leads to the familiar simplicity of a stateless REST API, but you know it ends in a slow, spinner-filled user experience and a swamp of useEffect boilerplate. Another leads to a powerful all-in-one sync platform, promising real-time magic but threatening to lock you into its proprietary ecosystem. A third path involves manually wrestling with a caching library, where you become the sole mechanic responsible for a complex, fragile engine.
For years, this has been our reality. We have been forced to choose: performance, or developer experience? Flexibility, or power?
This series is a roadmap to a new destination. It's a guide to an emerging architectural pattern that proves these trade-offs are a false choice. By composing a new generation of powerful, focused libraries, we can build applications that are incredibly fast, offline-capable, and even real-time, all without sacrificing control or developer sanity.
This is a journey in four builds, from a simple kit car to a finely tuned supercar.
This series is designed to be read in order, with each part providing the tools and mental models to tackle the next, more sophisticated challenge.
We begin where every developer starts: by moving past the "kit car" of manual fetch calls. Part 1 is about building a reliable, high-performance "sedan" for the modern web—an application that feels fast and dependable, even on a shaky network.
-
Problems We'll Solve:
- The Spinner Plague: How do we eliminate the constant
isLoadingstates that make our UI feel sluggish? - The Pain of Optimistic Updates: What is the right way to make the UI update instantly when a user clicks "save," without writing messy, imperative state management code?
- The Offline Void: How can we let a user continue to work and queue their changes when they go through a tunnel, instead of just showing a "You are offline" error?
- Boilerplate Hell: How do we stop rewriting the same data-fetching lifecycle (
try/catch/finally,isLoading/isError/data) for every single component?
- The Spinner Plague: How do we eliminate the constant
-
The Architecture We'll Build: We will leverage the powerful combination of TanStack Query (as our server cache and network engine) and TanStack DB (as our client-side reactive layer) on top of a traditional API.
-
The Outcome: You'll learn how to take any existing API and build an offline-first, high-performance frontend. This is the new baseline for excellence.
Read Part 1: The Pragmatic Foundation
Ambitious applications rarely live in a simple world. As they grow, they need to consume data from multiple sources. You have your core Postgres database, but now you need a collaborative feature powered by a real-time sync engine like Triplit or Convex.
-
The Problems We'll Solve:
- Protocol Fragmentation: How do you prevent your components from becoming a tangled mess of different data hooks (
useQueryfor HTTP,useConvexQueryfor WebSockets)? - The "Two-World" Problem: How can a single UI element cleanly display data from two separate universes? For example, showing a "Payment Issue" badge (from your Postgres DB) next to a task on a real-time project board (from your sync engine).
- Client-Side Joins: Is it possible to reactively join data from a REST API and a WebSocket stream on the client side, and can it actually be performant?
- Protocol Fragmentation: How do you prevent your components from becoming a tangled mess of different data hooks (
-
The Architecture We'll Build: We will upgrade our sedan to a tuned supercar. We'll show how TanStack DB can act as a unified data fabric, consuming data from multiple backends by designing a custom collection adapter. We'll introduce the concept of a
System of Record(your traditional API) vs. aSystem of Engagement(your real-time engine). -
The Outcome: You'll learn to orchestrate multiple backends, unlocking the ability to build incredibly powerful features that were previously an architectural nightmare.
Read Part 2: Taming the Hybrid World
The final part of our journey is about moving from a system that is merely powerful to one that is truly elegant. The hallmark of a great library is that it makes hard things simple.
-
The Problems We'll Solve:
- The Boilerplate Ceremony: The solution from Part 2 is robust but verbose. How do we eliminate the "two-step" process of creating a custom collection so it feels just as easy as any other?
- The API Design Challenge: How do you design a library abstraction that is simple for the 95% use case but still flexible enough for advanced power users?
- Ensuring Consistency: How do we provide a single, consistent pattern for creating collections, regardless of their underlying data source?
-
The Architecture We'll Build: We will encapsulate our powerful, low-level adapter within a high-level collection factory. This is the final layer of polish that transforms a set of powerful tools into a single, elegant, and production-ready API.
-
The Outcome: You will see the complete, fully-documented code for a theoretical
@tanstack/triplit-collectionpackage. You'll understand not just how to build a powerful integration, but how to design it in a way that is a joy for other developers to use.
Read Part 3: The Final Abstraction
This roadmap is for the frontend or full-stack developer who has felt the friction of modern state management and is seeking a better way. By the end of this series, you won't just have a collection of code snippets. You will have:
- A New Mental Model: The ability to see your frontend not just as a data fetcher, but as a sophisticated data orchestrator.
- A Practical Toolkit: The knowledge to build everything from a simple, offline-capable app to a complex, multi-backend data fabric.
- Architectural Confidence: The ability to look at any complex data requirement and see a clear, pragmatic path to implementing it without compromise.
The era of choosing between power and simplicity is over. A new, more powerful, and more pragmatic pattern is here. Let's begin the journey.
Part 1: From Kit Car to Sedan — The Pragmatic Foundation
This is Part 1 of "A Developer's Guide to the Modern Data Layer." If you haven't read Part 0: The End of Trade-Offs, start there for the complete roadmap.
Every frontend developer knows this story by heart. It starts innocently enough—a simple requirement to "fetch and display some data." You reach for the familiar tools: a useEffect hook, an axios call, maybe a useState to track loading.
What begins as a few lines of clean code quickly metastasizes into a hydra of edge cases. Race conditions emerge when users click too fast. Loading spinners flicker constantly. The app breaks entirely when the network hiccups. Your components become archaeological sites, layered with defensive code built to handle every possible failure mode.
You are building a kit car—functional, but demanding constant maintenance from the mechanic (you). Every new feature requires you to hand-build the same engine over and over again.
There's a better way. A way to move from this manual labor to driving a reliable, high-performance sedan that handles all the complex mechanics for you. This is the story of how to build that foundation.
Let's start with an honest look at where most of us learned to fetch data. This is the kit car—it gets you on the road, but the true cost reveals itself over time.
function TodoList() {
const [todos, setTodos] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isCancelled = false;
setIsLoading(true);
fetch('/api/todos')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
if (!isCancelled) {
setTodos(data);
setIsLoading(false);
}
})
.catch(err => {
if (!isCancelled) {
setError(err.message);
setIsLoading(false);
}
});
return () => { isCancelled = true; };
}, []);
const addTodo = async (text) => {
try {
setIsLoading(true);
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, completed: false })
});
if (!response.ok) throw new Error('Failed to add todo');
const newTodo = await response.json();
setTodos(prev => [...prev, newTodo]);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
<button onClick={() => addTodo('New todo')}>Add Todo</button>
</div>
);
}This component works, but look at what it doesn't handle:
- No caching: Navigate away and back, and the data refetches from scratch
- No request deduplication: Multiple instances of this component will trigger multiple identical requests
- No optimistic updates: Users wait for the server round-trip before seeing their changes
- No offline support: Loss of network means complete application failure
- No background refresh: Stale data stays stale until manual refresh
- Race condition prone: Despite the
isCancelledflag, complex interactions can still cause issues
Most critically, every component becomes a unique snowflake. Each one requires its own error handling, loading management, and state coordination. You become the full-time mechanic for an increasingly complex fleet of hand-built engines.
The breakthrough that changes everything is a fundamental realization: server state is not component state.
Component state is data that belongs to your UI—form inputs, modal visibility, selected tabs. This data lives and dies with your components, and managing it locally makes perfect sense.
Server state is completely different. It's a cached representation of data that lives elsewhere. It can become stale. Multiple components might need the same data. It needs to be synchronized, not just managed.
This insight led to the creation of TanStack Query (formerly React Query), which introduced a revolutionary mental model: treat server data as a cache with smart invalidation rather than as traditional state.
TanStack Query transforms our kit car approach into something far more sophisticated. Here's how our todo list looks with this upgrade:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const { data: todos = [], isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
}
});
const addTodoMutation = useMutation({
mutationFn: async (text) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, completed: false })
});
if (!response.ok) throw new Error('Failed to add todo');
return response.json();
},
onSuccess: () => {
// Invalidate and refetch the todos list
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
<button
onClick={() => addTodoMutation.mutate('New todo')}
disabled={addTodoMutation.isPending}
>
{addTodoMutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
</div>
);
}Already, we've gained tremendous power:
- Automatic caching: Navigate away and back—no refetch needed
- Request deduplication: Multiple components requesting the same data trigger only one network call
- Background refetching: Data stays fresh automatically
- Smart invalidation: Changes to todos automatically refresh the list
- Loading states: Built-in
isLoading,isPendingstates without manual management - Error handling: Consistent error patterns across all queries
But we're still not at our destination. We've solved the caching problem, but we haven't yet achieved the reactive, instant UI that users expect from modern applications.
Users don't want to wait for server responses to see their actions reflected in the UI. When they click "Add Todo," they expect to see that todo appear immediately, not after a network round-trip.
This is where optimistic updates come in—the practice of updating the UI immediately based on the assumption that the server operation will succeed, then reconciling with the actual server response later.
TanStack Query supports optimistic updates, but implementing them correctly requires careful coordination between the optimistic state and the eventual server truth. Here's what that looks like:
const addTodoMutation = useMutation({
mutationFn: async (text) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, completed: false })
});
if (!response.ok) throw new Error('Failed to add todo');
return response.json();
},
onMutate: async (newTodoText) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update to the new value
queryClient.setQueryData(['todos'], (old = []) => [
...old,
{ id: Date.now(), text: newTodoText, completed: false } // Temporary ID
]);
// Return a context object with the snapshotted value
return { previousTodos };
},
onError: (err, newTodoText, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Always refetch after error or success to ensure server state
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});This works, but it's getting complex. We're manually managing optimistic state, rollbacks, and reconciliation. Imagine scaling this pattern across dozens of different mutations in a real application. We're drifting back toward kit car territory—powerful, but requiring constant mechanical expertise.
This is where TanStack DB enters the picture. Think of it as the perfect complement to TanStack Query—where TanStack Query excels at caching and network management, TanStack DB excels at reactive, optimistic UI updates.
The key insight is that these two libraries have perfectly complementary responsibilities:
- TanStack Query: The engine and drivetrain—manages server communication, caching, and network reliability
- TanStack DB: The cockpit and suspension—provides instant, reactive UI updates with automatic optimistic behavior
Here's how our todo list transforms with this combination:
// First, we create a collection that bridges TanStack Query and TanStack DB
import { createCollection } from '@tanstack/react-db';
import { queryCollectionOptions } from '@tanstack/query-collection';
export const todoCollection = createCollection(
queryCollectionOptions({
// === TanStack Query's Job: Network & Caching ===
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
},
// Handle mutations by returning promises that TanStack Query manages
onInsert: async ({ transaction }) => {
const todo = transaction.mutations[0].modified;
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
if (!response.ok) throw new Error('Failed to add todo');
return response.json();
},
onUpdate: async ({ transaction }) => {
const { key, modified } = transaction.mutations[0];
const response = await fetch(`/api/todos/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(modified)
});
if (!response.ok) throw new Error('Failed to update todo');
return response.json();
},
onDelete: async ({ transaction }) => {
const key = transaction.mutations[0].key;
const response = await fetch(`/api/todos/${key}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete todo');
},
// === TanStack DB's Job: Reactive UI ===
getKey: (todo) => todo.id,
refetchOnMutation: true, // Auto-sync after mutations
})
);Now our component becomes beautifully simple:
import { useLiveQuery } from '@tanstack/react-db';
import { q } from '@tanstack/db';
function TodoList() {
const { data: todos = [] } = useLiveQuery(() =>
q.from(todoCollection)
);
const addTodo = () => {
todoCollection.insert({
id: Date.now(), // Temporary ID for optimistic update
text: 'New todo',
completed: false
});
};
const toggleTodo = (todo) => {
todoCollection.update(todo.id, {
completed: !todo.completed
});
};
const deleteTodo = (todoId) => {
todoCollection.delete(todoId);
};
return (
<div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}
onClick={() => toggleTodo(todo)}
>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
<button onClick={addTodo}>Add Todo</button>
</div>
);
}Look at what we've achieved:
- Zero loading states: The UI updates instantly, no spinners needed
- Automatic optimistic updates: Changes appear immediately, then sync in the background
- Automatic rollback: If a server operation fails, the UI automatically reverts
- Reactive queries: The component re-renders automatically when data changes
- Clean separation: Network logic is in the collection, UI logic is in the component
But we're not done yet. The sedan has one more powerful feature: offline reliability. When the network fails, TanStack Query doesn't just error out—it pauses mutations until connectivity returns.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
const persister = createAsyncStoragePersister({
storage: window.localStorage,
});
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<TodoApp />
</PersistQueryClientProvider>
);
}With this setup:
- Offline reading: Previously fetched data is available from localStorage
- Offline writing: Users can continue making changes; they're queued as "paused mutations"
- Automatic sync: When connectivity returns, all queued changes sync automatically
- Conflict resolution: Fresh server data reconciles with local optimistic state
The user experience is seamless. The app works offline as well as it does online.
The sedan can handle sophisticated use cases with ease. Here are some advanced patterns:
function FilteredTodoList({ showCompleted, sortBy }) {
const { data: todos = [] } = useLiveQuery(() =>
q.from(todoCollection)
.where(todo => showCompleted ? true : !todo.completed)
.orderBy(todo => sortBy === 'date' ? todo.createdAt : todo.text)
);
// Component automatically re-renders when filter or sort changes
// No manual state management needed
}function TodoStats() {
const { data: stats } = useLiveQuery(() =>
q.from(todoCollection)
.select(todos => ({
total: todos.length,
completed: todos.filter(t => t.completed).length,
pending: todos.filter(t => !t.completed).length
}))
);
return (
<div>
<p>Total: {stats?.total || 0}</p>
<p>Completed: {stats?.completed || 0}</p>
<p>Pending: {stats?.pending || 0}</p>
</div>
);
}// The collection automatically refetches in the background
// Components react to changes without any manual intervention
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
refetchInterval: 30000, // Refetch every 30 seconds
refetchOnWindowFocus: true, // Refetch when user returns to tab
// ... mutation handlers
})
);While the optimistic approach eliminates most loading states, you still have control when you need it:
function TodoList() {
const {
data: todos = [],
error,
isLoading
} = useLiveQuery(() => q.from(todoCollection));
// Show initial loading only on first load
if (isLoading && todos.length === 0) {
return <div>Loading todos...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{/* Your todo list */}
{error && (
<div className="error-banner">
Sync failed: {error.message}
<button onClick={() => queryClient.refetchQueries(['todos'])}>
Retry
</button>
</div>
)}
</div>
);
}This architecture isn't just more pleasant to work with—it's genuinely faster:
- Instant UI updates: Changes appear immediately, no network wait
- Smart caching: Data is cached and shared across components
- Minimal re-renders: Components only update when their specific data changes
- Background sync: Network operations happen asynchronously
- Request deduplication: Multiple components requesting the same data don't create multiple requests
This sedan architecture is ideal for:
- CRUD applications: Managing lists of data with create, read, update, delete operations
- Offline-first needs: Applications that must work reliably on poor networks
- Complex UI state: When you have filtering, sorting, and derived data requirements
- Team productivity: When you want to eliminate boilerplate and focus on features
- Existing APIs: When you have a traditional REST API and want to supercharge the frontend
This pattern covers the vast majority of frontend applications, but there are scenarios where you might need to upgrade further:
- Real-time collaboration: If multiple users are editing the same data simultaneously (like Google Docs), you'll need a specialized sync engine (covered in Part 2)
- Massive datasets: If you're dealing with millions of records, you might need virtualization and server-side pagination
- Complex authorization: If your data access patterns are extremely complex, a GraphQL solution might be more appropriate
We've built our sedan. Starting from the manual labor of kit car data fetching, we've created a foundation that is:
- Fast: Instant UI updates with background sync
- Reliable: Automatic offline support and error recovery
- Maintainable: Clean separation between network and UI concerns
- Scalable: Patterns that work from simple lists to complex applications
- Developer-friendly: Minimal boilerplate, maximum productivity
This is the new baseline—the reliable sedan that every modern web application should start from. In Part 2, we'll explore what happens when your application's ambitions outgrow even this solid foundation, and how to build a supercar that can handle multiple data sources and real-time collaboration.
But for now, you have everything you need to build fast, offline-capable, delightful web applications. The age of loading spinners and brittle state management is over. Welcome to the sedan.
Ready for more power? Continue to Part 2: From Sedan to Supercar — Taming the Hybrid World, where we'll tackle the challenges of multiple data sources and real-time synchronization.
Of course. Here is the rewritten "Part 2" introduction, updated to more closely match the direct, developer-focused voice of the inspiration text and to remove the abstract "System of Record/Engagement" language.
In Part 1, we arrived at a fantastic destination. We built our sedan: a robust architecture that delivered an instant, offline-capable UI with minimal boilerplate. It’s the perfect vehicle for the vast majority of web applications, elegantly handling caching and optimistic updates. For many, this is all the car they will ever need.
But what happens when the road changes? What happens when your application’s ambition pushes you beyond the city streets and onto the racetrack of real-time collaboration?
You might be tempted to trade in your reliable sedan for a purpose-built, all-in-one racecar—a specialized sync platform. But in doing so, you risk losing the flexibility and control of the powerful, traditional engine you’ve come to rely on. The solution isn't to trade one vehicle for another. It's to build a supercar—a hybrid marvel that combines the raw power of your traditional engine with the instantaneous response of a real-time electric motor.
Your application is a success. The sedan architecture from Part 1 has served you well, providing a fast, offline-capable UI on top of your existing REST or GraphQL API.
But now, you're building your next killer feature: a live project dashboard where team members' changes must be reflected on every other screen instantly. You correctly identify that this is a job for a specialized real-time sync engine. You choose a great one, like Triplit or Convex, to handle the complex, stateful, multi-user interactions.
Now you face a new, sophisticated problem. Your application's data is no longer from a single source. It's a hybrid, coming from:
- Your Traditional API: Data fetched via
useQueryfrom your REST/GraphQL backend. This is your source of truth for things like user profiles, billing info, and historical data. - Your Real-Time Sync Engine: A live stream of data from Triplit, likely consumed with a library-specific hook (e.g.,
useTriplitQuery). This powers the collaborative state.
The first casualty of this split is your component layer, which now has to awkwardly fetch, combine, and manage loading/error states from two completely different data-fetching clients.
This is where we upgrade from a sedan to a supercar. We leverage TanStack DB not just as a reactive shell for TanStack Query, but as a data-source-agnostic orchestration layer. Its job is to sit above your specialized data clients and provide a single, coherent data fabric to your application.
The architecture is simple in concept: you create a TanStack DB Collection for each data source.
- For your Traditional API: We use the same
@tanstack/query-collectionadapter from Part 1, piping data from TanStack Query into a collection. - For your Real-Time Sync Engine: This requires building a custom collection adapter. Let's be clear: this is a significant, one-time infrastructure investment. You are building a "bridge" that translates your sync engine's specific protocol into TanStack DB's standard interface. While not trivial, this bridge is a high-leverage piece of code that, once built, allows your entire team to consume real-time data with blissful simplicity.
A skeptic will immediately ask, "Isn't joining data from two different sources on the client a terrible idea for performance?"
The answer is no, because of where the join happens. A useLiveQuery join does not trigger any new network requests. It operates on the data that has already been synced to the client and is sitting in memory. TanStack DB's query engine (d2ts, a differential dataflow implementation) is designed to perform these in-memory joins and aggregations at sub-millisecond speeds. The performance is closer to filtering a local array than it is to a slow, server-side database join.
The key is that you are joining two streams of local, cached data, not waiting on two separate network requests.
The true value of this architecture is that it enables you to build features that are otherwise an architectural nightmare. Forget joining a user's name to their live cursor—let's solve a real business problem.
Scenario: On your collaborative project board (from Triplit), you need to display a prominent "At Risk" badge on any task assigned to a user whose payment subscription has failed (data from your Stripe-backed Postgres DB).
Without a unified layer, this is incredibly difficult. With our data fabric, the solution is breathtakingly elegant:
import { useLiveQuery } from '@tanstack/react-db';
import { q, eq } from '@tanstack/db';
import { tasksCollection } from './collections/tasks.collection';
import { paymentStatusCollection } from './collections/payments.collection';
function ProjectBoard() {
// A single, declarative query to find all tasks that are at risk.
const { data: atRiskTasks } = useLiveQuery((q) =>
q
.from({
tasks: tasksCollection, // Live data from Triplit
payments: paymentStatusCollection, // Cached data from REST API
})
// Join the live tasks with the transactional payment data...
.join(({ t, p }) => eq(t.assigneeId, p.userId))
// ...and filter in real-time to find only the problem cases.
.where(({ p }) => eq(p.status, 'failed'))
.select(({ t }) => t) // Select the full task object
);
const atRiskTaskIds = new Set(atRiskTasks.map(t => t.id));
// Now, in your main board component...
// <TaskCard isAtRisk={atRiskTaskIds.has(task.id)} />
}This is the power of orchestration. The atRiskTasks list is live. If a user's payment fails and the paymentStatusCollection updates (thanks to TanStack Query's refetchOnWindowFocus), the badge will appear automatically on the correct tasks. If a task is reassigned in real-time (via Triplit), the badge will appear or disappear instantly. This level of dynamic, cross-source reactivity is impossible to achieve cleanly with a fragmented data layer.
Building this supercar is an investment. The return on that investment is an architecture that is not just powerful, but resilient.
- You Use the Best Tool for the Job: You don't compromise. Your transactional logic lives in your robust backend. Your collaborative state lives in a purpose-built sync engine.
- Component Simplicity: Your components are decoupled from the network. They simply request data from the unified fabric, making them easier to test, reuse, and reason about.
- Future-Proof by Design: When your company adds a third data source (a GraphQL API, another microservice), you don't rewrite your UI. You build one more adapter. Your data fabric simply expands.
We've built our supercar. We've taken the reliable engine from our sedan and paired it with a high-performance electric motor, all unified by a sophisticated chassis. We have the power of our traditional backend and the immediacy of a real-time engine, working in perfect harmony.
But there is one final step. The wiring of our supercar is still a bit exposed. Our custom adapters are powerful, but using them requires some ceremony. How do we polish this final layer and create an API that is not just powerful, but truly elegant to use?
That is the final part of our journey.
This is the final part of "A Developer's Guide to the Modern Data Layer." In Part 1, we built our reliable "sedan." In Part 2, we upgraded it to a "supercar" by creating a unified data fabric that orchestrates multiple backends.
In Part 2, we achieved something remarkable. We built our supercar: a sophisticated frontend architecture capable of harmonizing data from a traditional REST API and a specialized real-time sync engine. By creating a custom collection adapter, we proved it was possible to tame the complexity of a hybrid backend and present it to our UI components as a single, unified data layer. Our useLiveQuery hooks can now effortlessly join live, collaborative state with robust, transactional data.
The engineering is complete. The performance is incredible. The car is on the track, and it is winning races.
But there is one last step before it's ready for the showroom. The driver's seat is still cluttered with exposed wiring and manual switches. Our powerful infrastructure works, but it isn't yet elegant. This is the final and most crucial step in any great engineering endeavor: taking a powerful system and making it a joy to use.
Our custom adapter from Part 2 is a brilliant piece of infrastructure. It perfectly encapsulates the complex logic of bridging a real-time engine with TanStack DB's sync protocol. But using it still requires a two-step ceremony from the developer:
// The "powerful but verbose" two-step process:
import { createCollection } from '@tanstack/react-db';
// Our lower-level adapter, the powerful "engine" we built
import { createTriplitCollectionOptions } from './my-awesome-adapter';
import { client } from './my-triplit-client';
// --- Step 1: Create the specialized options object ---
const myTaskOptions = createTriplitCollectionOptions({
client,
query: client.query('tasks'),
getKey: (task) => task.id,
});
// --- Step 2: Create the actual collection, manually combining with standard options ---
const tasksCollection = createCollection({
...myTaskOptions,
id: 'project-123-tasks',
schema: zodTaskSchema,
});This works perfectly, but it's not ideal. It exposes the internal assembly process to the end developer. It requires them to import two separate functions, manually compose an object with the spread operator, and understand the distinction between the adapter's options and the standard createCollection options.
While the core complexity is managed, the developer still feels the friction. For every real-time collection we want to create, we have to repeat this manual ceremony. The supercar is fast, but it requires a pre-flight checklist to start.
Great tools don't just solve problems; they provide elegant abstractions. Instead of exposing our low-level createTriplitCollectionOptions adapter as the primary API, we will encapsulate it within a single, high-level factory function.
This function, createTriplitCollection, becomes the main export of our integration package. Its sole purpose is to provide a clean, one-step interface for creating a Triplit-powered collection. It's the polished dashboard that hides the complex wiring, presenting the driver with a simple "start" button.
Here is the final, fully-documented code for a production-grade @tanstack/triplit-collection package. It includes both our robust core logic (which can now be treated as an internal implementation detail) and the elegant factory function that developers will reach for every time.
This is the powerful engine we engineered in Part 2. Its code remains unchanged—it's still the robust, tested heart of our integration. It translates the real-time stream and handles mutations. (The full implementation is collapsed here for brevity, but it is the same robust, error-handled version from our previous final draft).
// @tanstack/triplit-collection/src/options.ts (internal module)
// ... a bunch of imports from @tanstack/db and @triplit/client
export function createTriplitCollectionOptions(options) {
// ... All the robust logic from Part 2, handling the syncFn,
// onInsert, onUpdate, and onDelete.
}This is the beautiful, simple public API that developers will interact with, exported from the package's main entry point.
// @tanstack/triplit-collection/src/index.ts (main export)
import {
createCollection,
Collection,
StandardSchema,
CollectionConfig,
} from '@tanstack/db';
import {
TriplitClient,
SchemaQuery,
Models,
TriplitError,
} from '@triplit/client';
import { createTriplitCollectionOptions } from './options';
// This clever utility type allows users to pass through any standard CollectionConfig
// property (like `rowUpdateMode`) without us needing to redefine them all.
type PassthroughCollectionConfig<TItem> = Omit<
CollectionConfig<TItem>,
'getKey' | 'sync' | 'onInsert' | 'onUpdate' | 'onDelete'
>;
/**
* Configuration options for the `createTriplitCollection` factory function.
*/
export interface TriplitCollectionFactoryOptions<
M extends Models<M>,
TQuery extends SchemaQuery<M>,
TItem extends TQuery['_output'] = TQuery['_output']
> extends PassthroughCollectionConfig<TItem> {
/**
* An instance of the configured TriplitClient.
*/
client: TriplitClient<M>;
/**
* The Triplit query that defines the set of data to be synced into this collection.
*
* @example client.query('tasks').where('projectId', '=', 'proj-123')
*/
query: TQuery;
/**
* A function that returns a unique key (ID) for a given item. This is mandatory
* for TanStack DB to track individual entities.
*/
getKey: (item: TItem) => string | number;
/**
* An optional, but highly recommended, unique identifier for this collection.
* This ID is used in developer tools and for debugging, making it an essential
* part of a scalable application.
*/
id?: string;
/**
* An optional schema (e.g., from Zod). This is a powerhouse feature. Providing a
* schema enables full, end-to-end type safety for all mutations. For example,
* `collection.insert(data)` will be type-checked against your schema.
*/
schema?: StandardSchema<TItem>;
/**
* An optional callback to handle errors that occur during the real-time subscription
* or during mutation operations. Useful for global error logging or displaying toasts.
* @param error - The error received from the Triplit client.
*/
onError?: (error: TriplitError | Error) => void;
}
/**
* Creates a new TanStack DB Collection that is kept in sync with a Triplit real-time query.
*
* This factory function is the primary, high-level API for integrating Triplit with TanStack DB.
* It provides a streamlined, one-step process for creating fully reactive, offline-first,
* and optimistically updated collections powered by a Triplit backend.
*
* @param options - The configuration options for the Triplit-powered collection.
* @returns An instance of a TanStack DB `Collection`, ready to be used with `useLiveQuery`.
*/
export function createTriplitCollection<
M extends Models<M>,
TQuery extends SchemaQuery<M>,
TItem extends TQuery['_output'] = TQuery['_output']
>(
options: TriplitCollectionFactoryOptions<M, TQuery, TItem>
): Collection<TItem> {
const {
client,
query,
getKey,
onError,
// Capture all other standard options into a 'rest' object.
...restConfig
} = options;
// 1. Generate the core sync and mutation handlers using our lower-level adapter.
const triplitAdapterOptions = createTriplitCollectionOptions<M, TQuery, TItem>({
client,
query,
getKey,
onError,
});
// 2. Create and return the final TanStack DB Collection, combining the adapter's
// logic with all the standard configuration properties passed in by the user.
return createCollection<TItem>({
...restConfig, // Spread the passthrough options first (id, schema, rowUpdateMode, etc.)
...triplitAdapterOptions, // Then spread our adapter's core logic.
});
}Look at the final result for the developer using our library:
import { createTriplitCollection } from '@tanstack/triplit-collection'; // One simple import
import { client } from './my-triplit-client';
import { zodTaskSchema } from './schemas';
// A single, elegant, declarative function call.
export const tasksCollection = createTriplitCollection({
client,
query: client.query('tasks').where('projectId', '=', 'proj-123'),
getKey: (task) => task.id,
id: 'project-123-tasks',
schema: zodTaskSchema,
});This is the power of a final, thoughtful abstraction. We have traveled from the chaos of manual fetching, through the power of caching and adapters, to the elegant simplicity of a fully orchestrated data layer.
Our application architecture is now complete. It's a showroom model, featuring:
- A High-Performance Hybrid Engine: Composing a traditional REST API with a real-time sync engine.
- A Unified Chassis: Using TanStack DB as a data fabric to harmonize all data sources.
- A Simple, Polished Dashboard: Providing developers with a clean, high-level factory function that makes this immense power effortless to wield.
We didn't have to sacrifice our traditional backend or the immediacy of a real-time engine. We didn't have to compromise between power and simplicity. We found a way to have it all. This is the new standard for building ambitious, scalable, and delightful modern web applications.