The Easiest Way to Build Reactive Local-First Apps with TinyBase and PowerSync
I’ve written about the advantages of offline-first and how easy it is to implement and maintain offline-first apps with PowerSync before. Now, while building apps in an offline-first approach makes data access and state handling considerably easier than dealing with remote API calls all the time, there are still ways to make implementing reactive UI even more efficient.
What is TinyBase?
TinyBase is a reactive data store for local-first apps built by James Pearce, a former Engineering Director at Meta. At its core, it provides local stores with either key-value or tabular data that you can bind your UI to (with out-of-the-box support for React). With no custom rendering logic required, the UI changes whenever the data changes. This makes implementations of data-heavy applications easy and incredibly performant. However, a local data store is only part of the story; most applications must load this data from somewhere and save it upon changes. This is where TinyBase persisters come into play - you can think of them as target-specific adapters in charge of loading from and saving to local or remote data sources like databases, API endpoints, or sync services. The design of TinyBase allows for a very flexible combination of stores and persisters, which means different stores can connect to different data sources, or one store can connect with multiple data sources and effectively sync between them.
What is PowerSync?
PowerSync is a Postgres to SQLite sync layer that allows you to build local-first apps with various technologies like React and React Native, Flutter, JavaScript web frameworks, or even Kotlin and Swift. Using “Sync Rules,” PowerSync solves the two most challenging parts of building offline-first apps in a very elegant and straightforward way: Determining which data to sync for which user/device and then executing the sync by reconciling the differences between the local state and server state. PowerSync works with any Postgres backend, but the easiest way to get started is to connect it to Supabase using the existing integration.
Why use TinyBase and PowerSync together?
If you’re not yet building apps with an offline-first approach, refer to my earlier blog post about offline-first apps for a discussion of the benefits of this approach.
If you’re already using TinyBase, you have a very efficient local setup that lets you develop reactive UI optimized for performance with very little code and powerful local query capabilities. The question remains, though, of how to populate the local data store and how to persist in changes to a backend service. Most apps are multi-user, if not multi-tenant apps, so this task gets complicated by the question of which backend data to sync to which local store. This is where PowerSync comes in, which allows you to define Sync Rules that encapsulate exactly this logic succinctly and take care of the synchronization behind the scenes. This makes it very easy to work with a local set of data and be sure that it’s a correct representation of the backend state in terms of the scope of data the user is supposed to access and the most recent version of this data.
If you’re already using PowerSync, data scoping and syncing are solved for you. PowerSync also allows you to implement reactive queries using their local SDKs to build highly performant local-first apps, including reactive UI, without any additional tooling. In my opinion, there are still benefits to gain from adding TinyBase as the local data store:
- The flexibility to add arbitrary combinations of stores and persisters makes you very flexible in your setup of data sources and storage. You can combine multiple data sources (like synced data from a Postgres backend via PowerSync, data accessed from REST endpoints, and collaborative data from a YJS endpoint) into a single data pane that your app’s UI handles consistently irrespective of the data’s source.
- This setup further encapsulates the local data handling and comes with its query builder TinyQL, which is not unlike an ORM. Just like with ORMs, there are arguments in favor and against this approach, and some people will prefer to avoid any further layers of abstractions and instead write raw SQL queries to query the local PowerSync database. I like the ORM approach, and especially in projects where specialized roles (or even distinct teams) work on different aspects of an app, I see a lot of value in giving the “UI team” a single data access paradigm with TinyBase. TinyQL’s syntax is close enough to SQL to be intuitive for everyone familiar with SQL but succinct and constrained enough to be accessible for someone tasked with implementing UI components who might not want to “learn backend stuff like databases.”
- Finally, the aforementioned decoupling of the “UI data layer” from the “backend data layer” makes it easier to switch data sources without re-implementing large parts of the frontend code base. This allows for selectively changing data storage approaches for parts of the UI (for example, when making parts of an app collaborative by adding CRDT capabilities via YJS or Automerge) or, frankly, for swapping out entire backend setups (for example, to switch from individually implemented REST endpoints to a hosted Supabase instance automatically synchronized via PowerSync).
How to integrate TinyBase and PowerSync?
Integrating TinyBase and PowerSync is straightforward. Since the recent 4.8 release, TinyBase ships with a PowerSync persister built-in. Follow the PowerSync docs to set up the PowerSync instance, including appropriate Sync Rules and connecting the local SDK. Then, follow the TinyBase setup to create a local store and add the PowerSync persister, passing it an instance of the local PowerSync database. Here’s a simple example for setting up the store and persister in React:
import { usePowerSync } from "@journeyapps/powersync-sdk-react-native";
import { createStore } from "tinybase/store";
import { createPowerSyncPersister } from "tinybase/persisters/persister-powersync";
const powerSync = usePowerSync();
const store = createStore();
const persister = createPowerSyncPersister(store, powerSync, {
mode: "tabular",
tables: {
load: {
profiles: {
tableId: "profiles",
rowIdColumnName: "id",
},
contacts: {
tableId: "contacts",
rowIdColumnName: "id",
},
},
save: {
profiles: {
tableName: "profiles",
rowIdColumnName: "id",
},
contacts: {
tableName: "contacts",
rowIdColumnName: "id",
},
},
},
});
Conclusion
If TinyBase takes care of your local data storage and accurately reflects your data in the UI state, and PowerSync takes care of automatically syncing an appropriate scope of data with a Postgres backend (potentially taken care of for you by Supabase), what is left for you as a developer to take care of? I would argue that’s the beauty of this tech stack. I can’t think of a better way to concentrate on the core capabilities of your app, making sure it delivers value to users with intuitive screen interactions without worrying about data access, state, and performance.