PowerSync and Supabase for Local-First Data Management
Introduction
This guide helps you integrate PowerSync with Supabase in an Ignite app for efficient local-first data management.
PowerSync allows your app to work smoothly offline while keeping the data in sync with your backend database.
What is PowerSync?
PowerSync is a service which synchronizes local data with your Postgres SQL back end. It lets your app work with a local copy of the users' data and automatically syncs changes to and from your backend.
Because your application interacts with the data in a local instance of SQLite, it means you'll always have fast, responsive access -- no fetching, no spinners. It also means your users will have a seamless, consistent experience even if they are offline.
In the background, PowerSync queues any changes and syncs the local data with the server whenever an internet connection becomes available. That means the data stays up to date across all of their devices.
Benefits of Using PowerSync
- Handles Intermittent Network Connectivity: PowerSync allows your app to remain operational even in areas with unreliable internet access. Users can continue their tasks without interruption, with automatic syncing when the connection is restored.
- Enables Offline Operation: With PowerSync, your application can fully function offline, allowing users to access and interact with their data regardless of their internet connection status.
- Eliminates Loading Delays: Leveraging local data minimizes the need for loading indicators, offering a smoother, faster experience for the user.
- Supports Multi-device Sync: PowerSync ensures data consistency across all of a user's devices, enabling seamless access and transition between different platforms. By integrating PowerSync into your Ignite project, you provide a more reliable and user-friendly experience, ensuring your application remains functional and responsive under various network conditions.
Using Other Backends
While this recipe uses Supabase for the backend, PowerSync can connect to almost any Postgres SQL backend and the process will be largely identical for other types of Postgres backends.
The major difference is that when the time comes, you will need to implement a PowerSyncBackendConnector
for your database in place of the SupabaseConnector
.
Check the PowerSync documentation for more information on connecting your database to PowerSync.
Prerequisites
To complete this recipe you'll need:
-
An Ignite app using
Expo CNG
workflowCreate a new Ignite app using the Ignite CLI::
npx ignite-cli@latest new PowerSyncIgnite --remove-demo --workflow=cng --yes
infoPowerSync requires native modules, so we need to use Expo with Continuous Native Generation (CNG).
If you are adding PowerSync to an app that currently uses Expo GO, you'll need to update your project to use either CNG or a development build.
-
A Supabase Project set up and connected to a PowerSync
- Follow the PowerSync + Supabase Integration Guide to get this set up -- both PowerSync and Supabase have free tiers that you can use to get started.
- Configure or Disable Supabase Email Verification
- By default, Supabase requires email verification for new users. This should be configured for any production apps.
- For the purposes of this recipe, you can disable this in the Supabase dashboard under:
- Authentication > Providers > Email > Confirm Email
Installing SDK and Dependencies
Install necessary dependencies for PowerSync.
First install the PowerSync SDK and its dependencies.
npx expo install \
@powersync/react-native \
@journeyapps/react-native-quick-sqlite
These dependencies include native modules so you'll need to rebuild your app after installing.
Install necessary dependencies for Supabase
First we need to install the Supabase SDK.
npx expo install @supabase/supabase-js
and we'll also need to install @react-native-async-storage/async-storage
for persisting the Supabase session.
npx expo install @react-native-async-storage/async-storage
Metro Configuration
The default Metro configuration uses inline requires. The @powersync/react-native
package does not work well with inline requires. Update the Metro config to not use inline requires for the PowerSync SDK.
// `metro.config.js`:
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config")
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname)
config.transformer.getTransformOptions = async () => ({
transform: {
// Inline requires are very useful for deferring loading of large dependencies/components.
// For example, we use it in app.tsx to conditionally load Reactotron.
// However, this comes with some gotchas.
// Read more here: https://reactnative.dev/docs/optimizing-javascript-loading
// And here: https://github.com/expo/expo/issues/27279#issuecomment-1971610698
inlineRequires: {
blockList: {
[require.resolve("@powersync/react-native")]: true,
// require() calls anywhere else will be inlined, unless they
// match any entry nonInlinedRequires.
},
},
},
})
// This helps support certain popular third-party libraries
// such as Firebase that use the extension cjs.
config.resolver.sourceExts.push("cjs")
module.exports = config
Authenticating with Supabase
PowerSync needs a valid session token to authenticate your Supabase users. In the next section we'll implement a basic authentication flow that will let us sign in and out of Supabase.
Add Supabase Config Variables to BaseConfig
First add your Supabase config to your app's configuration. In ignite apps, config is kept in app/config/config.base.ts
.
You'll need:
- supabaseUrl: Found through your Supabase dashboard under: Project Settings > API > Project URL.
- supabaseAnonKey: Found through your Supabase dashboard under: Project Settings > API > Project API keys.
// `app/config/config.base.ts`:
// update the interface to include the new properties
export interface ConfigBaseProps {
// Existing config properties
supabaseUrl: string
supabaseAnonKey: string
}
// Add the new properties to the config object
const BaseConfig: ConfigBaseProps = {
// Existing config values
supabaseUrl: '<<YOUR_SUPABASE_URL>>',
supabaseAnonKey: '<<YOUR_SUPABASE_ANON_KEY>>',
}
export default BaseConfig;
If you have different configurations for different environments, you can add these properties to config.dev.ts
and config.prod.ts
as needed.
Initialize the Supabase Client
Create app/services/database/supabase.ts
and add the following code to initialize the Supabase client:
// app/services/database/supabase.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from "@supabase/supabase-js"
import Config from '../../config'
export const supabase = createClient(Config.supabaseUrl, Config.supabaseAnonKey, {
auth: {
persistSession: true, storage: AsyncStorage,
},
})
Unlike web environments where localStorage
is available, in React Native Supabase requires us to provide a key-value store to hold the session token.
We're using AsyncStorage
here for simplicity, but if you need more security, Supabase provides an example of encrypting the session token using expo-secure-storage
in their React Native Auth example
Create useAuth
Hook and AuthContext
Next we'll create a hook and context to manage the authentication state and make it accessible to our components.
Create app/services/database/use-auth.tsx
and add the following code:
// app/services/database/use-auth.tsx
import { User } from "@supabase/supabase-js"
import { supabase } from "app/services/database/supabase"
import React, { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from "react"
type AuthContextType = {
signIn: (email: string, password: string) => void
signUp: (email: string, password: string) => void
signOut: () => Promise<void>
signedIn: boolean
loading: boolean
error: string
user: User | null
}
// We initialize the context with null to ensure that it is not used outside of the provider
const AuthContext = createContext<AuthContextType | null>(null)
/**
* AuthProvider manages the authentication state and provides the necessary methods to sign in, sign up and sign out.
*/
export const AuthProvider = ({ children }: PropsWithChildren<any>) => {
const [signedIn, setSignedIn] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [user, setUser] = useState<User | null>(null)
// Sign in with provided email and password
const signIn = useCallback(async (email: string, password: string) => {
setLoading(true)
setError("")
setUser(null)
try {
// get the session and user from supabase
const {
data: {session, user},
error
} = await supabase.auth.signInWithPassword({ email, password })
// if we have a session and user, sign them in
if (session && user) {
setSignedIn(true)
setUser(user)
// otherwise sign them out and set an error
} else {
throw new Error(error?.message);
setSignedIn(false)
setUser(null)
}
} catch (error: any) {
setError(error?.message ?? "Unknown error")
setSignedIn(false)
setUser(null)
} finally {
setLoading(false)
}
}, [
setSignedIn, setLoading, setError, setUser, supabase
])
// Create a new account with provided email and password
const signUp = useCallback(async (email: string, password: string) => {
setLoading(true)
setError("")
setUser(null)
try {
const { data, error } = await supabase.auth.signUp({ email, password })
if (error) {
setSignedIn(false)
setError(error.message)
} else if (data.session) {
await supabase.auth.setSession(data.session)
setSignedIn(true)
setUser(data.user)
}
} catch (error: any) {
setUser(null)
setSignedIn(false)
setError(error?.message ?? "Unknown error")
} finally {
setLoading(false)
}
}, [
setSignedIn, setLoading, setError, setUser, supabase
])
// Sign out the current user
const signOut = useCallback(async () => {
setLoading(true)
await supabase.auth.signOut()
setError("")
setSignedIn(false)
setLoading(false)
setUser(null)
}, [
setSignedIn, setLoading, setError, setUser, supabase
])
// Always memoize context values as they can cause unnecessary re-renders if they aren't stable!
const value = useMemo(() => ({
signIn, signOut, signUp, signedIn, loading, error, user
}), [
signIn, signOut, signUp, signedIn, loading, error, user
])
return <AuthContext.Provider value={ value }>{ children }</AuthContext.Provider>
}
export const useAuth = () => {
const context = useContext(AuthContext)
// It's a good idea to throw an error if the context is null, as it means the hook is being used outside of the provider
if (context === null) {
throw new Error('useAuthContext must be used within a AuthProvider')
}
return context
}
For more information on setting up authentication with Supabase (including setting up for OAuth providers like Github, Google and Facebook), refer to the Supabase Auth documentation.
If authentication starts acting up, try refreshing your app from the Debug menu. Auth state is managed at the app level and that can cause issues with hot reloading.
Provide Auth State to Your Application
Wrap your app with the AuthProvider
to provide the authentication state to your app:
// app/app.tsx
// ...other imports
import { AuthProvider } from "app/services/database/use-auth"
// ...
function App(props: AppProps) {
// ...
return (
<AuthProvider>
<SafeAreaProvider>
{/* ... */ }
</SafeAreaProvider>
</AuthProvider>
)
}
Create AuthScreen
for signing in / registering
Use the Ignite CLI to generate a new screen for signing in:
npx ignite-cli generate screen Auth
This will:
- create a new screen in
app/screens/AuthScreen.tsx
, - add that screen to the
AppNavigator
inapp/navigators/AppNavigator.tsx
, and - update the
Params
andScreenProps
types
Then open app/screens/AuthScreen.tsx
and update the AuthScreen
component to use the useAuth
hook to sign in.
When the user signs in successfully, the app will automatically navigate to the Welcome
screen.
// app/screens/AuthScreen.tsx
import { AppStackScreenProps } from "app/navigators"
import { Button, Screen, Text, TextField } from "app/components"
import { useAuth } from "app/services/database/use-auth"
import React, { useEffect, useState } from "react"
import { ActivityIndicator, Modal, TextStyle, View, ViewStyle } from "react-native"
import { colors, spacing } from "../theme"
interface AuthScreenProps extends AppStackScreenProps<"Auth"> {}
export const AuthScreen: React.FC<AuthScreenProps> = ({ navigation }) => {
const { signUp, signIn, loading, error, user } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSignIn = async () => {
signIn(email, password)
}
const handleSignUp = async () => {
signUp(email, password)
}
useEffect(() => {
if (user) {
navigation.navigate("Welcome")
}
}, [user])
return (
<Screen style={ $container } safeAreaEdges={ ["top"] }>
<Text preset={ "subheading" }>PowerSync + Supabase</Text>
<Text preset={ "heading" }>Sign in or Create Account</Text>
<TextField
inputWrapperStyle={ $inputWrapper }
containerStyle={ $inputContainer }
label={ "Email" }
value={ email }
inputMode={ "email" }
onChangeText={ setEmail }
keyboardType="email-address"
autoCapitalize={ "none" }
/>
<TextField
containerStyle={ $inputContainer }
inputWrapperStyle={ $inputWrapper }
label={ "Password" }
value={ password }
onChangeText={ setPassword }
secureTextEntry
/>
<View style={ $buttonContainer }>
<Button
disabled={ loading }
text={ "Sign In" }
onPress={ handleSignIn }
style={ $button }
preset={ "reversed" }
/>
<Button
disabled={ loading }
text={ "Register New Account" }
onPress={ handleSignUp }
style={ $button }
/>
</View>
{ error ? <Text style={ $error } text={ error }/> : null }
<Modal transparent visible={ loading }>
<View style={ $modalBackground }>
<ActivityIndicator size="large" color={ colors.palette.primary500 }/>
</View>
</Modal>
</Screen>
)
}
const $container: ViewStyle = {
backgroundColor: colors.background,
flex: 1,
justifyContent: "center",
paddingHorizontal: spacing.lg,
}
const $inputContainer: TextStyle = {
marginTop: spacing.md,
}
const $inputWrapper: TextStyle = {
backgroundColor: colors.palette.neutral100,
}
const $modalBackground: ViewStyle = {
alignItems: "center",
backgroundColor: "#00000040",
flex: 1,
flexDirection: "column",
justifyContent: "space-around",
}
const $error: TextStyle = {
color: colors.error,
marginVertical: spacing.md,
textAlign: "center",
width: "100%",
fontSize: 20,
}
const $buttonContainer: ViewStyle = {
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
marginVertical: spacing.md,
}
const $button: ViewStyle = {
marginTop: spacing.xs,
}
Create the SignOutButton
component
First use the ignite CLI to generate a new screen for signing out:
npx ignite-cli generate component SignOutButton
Then, update the component to show a button that calls signOut
from our useAuth
when pressed.
// app/components/SignOutButton.tsx
import { Button } from "app/components/Button"
import { useAuth } from "app/services/database/use-auth"
import * as React from "react"
import { StyleProp, View, ViewStyle } from "react-native"
import { observer } from "mobx-react-lite"
import { spacing } from "app/theme"
export interface SignOutButtonProps {
/**
* An optional style override useful for padding & margin.
*/
style?: StyleProp<ViewStyle>
}
/**
* Describe your component here
*/
export const SignOutButton = observer(function SignOutButton(props: SignOutButtonProps) {
const { style } = props
const $styles = [$container, style]
const { signOut } = useAuth()
const handleSignOut = async () => {
await signOut()
}
return (
<View style={ $styles }>
<Button text="Sign Out" onPress={ handleSignOut }/>
</View>
)
})
const $container: ViewStyle = {
padding: spacing.md,
}
Add SignOutButton
to WelcomeScreen
Right now we just want to confirm that our authentication is working, so lets clear out the Welcome screen and add the SignOutButton
to it.
// app/screens/WelcomeScreen.tsx
import { SignOutButton } from "app/components"
import { observer } from "mobx-react-lite"
import React, { FC } from "react"
import { ViewStyle } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import { colors } from "../theme"
interface WelcomeScreenProps
extends NativeStackScreenProps<SignedInNavigatorParamList, "Welcome"> {}
export const WelcomeScreen: FC<WelcomeScreenProps> = observer(function WelcomeScreen() {
return (
<SafeAreaView style={$container}>
<SignOutButton />
</SafeAreaView>
)
})
const $container: ViewStyle = {
flex: 1,
backgroundColor: colors.palette.neutral300,
display: "flex",
justifyContent: "flex-start",
height: "100%",
flexDirection: "column",
}
Update the AppNavigator
We don't want to allow users to navigate to the Welcome screen if they are not signed in.
We can ensure this by checking the user's authentication status in the AppNavigator
and only allowing them to access the Auth
screen if they are not signed in.
// app/navigators/AppNavigator.tsx
import { useAuth } from "app/services/database/use-auth"
//...
const AppStack = observer(function AppStack() {
// Fetch the user from the auth context
const { signedIn } = useAuth()
return (
<Stack.Navigator
screenOptions={ { headerShown: false, navigationBarColor: colors.background } }
>
{/**
* by wrapping the Welcome screen in a conditional, we ensure that
* the user can only access it if they are signed in
*/ }
{ signedIn
? <Stack.Screen name="Welcome" component={ Screens.WelcomeScreen }/>
: null
}
<Stack.Screen name="Auth" component={ Screens.AuthScreen }/>
{/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */ }
</Stack.Navigator>
)
})
// ...
For more information on Authentication flows with react-navigation
see their docs: https://reactnavigation.org/docs/auth-flow/
Checking In
At this point you should be able to:
- sign in and sign out of your app
- sign up for a new account.
If you are getting errors about an unverified email -- remember to disable email verification in the Supabase dashboard as described in the Prerequisites section.
This is a good time to commit your changes!
Connecting PowerSync to Supabase
Now that we have a valid Supabase session, we can connect to PowerSync and start syncing data between the local database and the backend.
We'll need to:
- Add our PowerSync URL to the app configuration
- Define our data schema, and
- Connect to the database
Add your PowerSync URL to your app config
Just like we did with supabase, add the PowerSync URL to your app's configuration.
Your PowerSync URL can be found in your PowerSync dashboard:
- Click on the Edit Instance button for your instance
- Copy the Instance URL from the dialog that appears.
// app/config/config.base.ts:
// update the interface to include the new properties
export interface ConfigBaseProps {
// Existing config properties
supabaseUrl: string
supabaseAnonKey: string
powersyncUrl: string
}
// Add the new properties to the config object
const BaseConfig: ConfigBaseProps = {
// Existing config values
supabaseUrl: '<<YOUR_SUPABASE_URL>>',
supabaseAnonKey: '<<YOUR_SUPABASE_ANON_KEY>>',
powersyncUrl: '<<YOUR_POWER_SYNC_URL>>',
}
Define Your Schema
First we need to define the schema for our database and TypeScript types in app/services/database/schema.ts
.
The Schema defines the data that PowerSync will sync between the local SQLite database and the backend.
From the PowerSync docs:
The types available are
TEXT
,INTEGER
andREAL
. These should map directly to the values produced by the Sync Rules. If a value doesn't match, it is cast automatically.
Typescript Types
It's good to also define TypeScript types for your records, as this will help with type checking and autocompletion in your code.
Keeping them with the schema will help keep your code organized and easy to maintain.
PowerSync supports Kysely, which enables automatically generation of typescript types for your database. See this announcement for more information on how to set that up for your project.
The Schema for our Todo App
Here is the schema we'll be using for our todo app:
// app/services/database/schema.ts
import { column, Schema, TableV2 } from '@powersync/react-native';
export const LISTS_TABLE = 'lists';
export const TODOS_TABLE = 'todos';
const todos = new TableV2(
{
list_id: column.text,
created_at: column.text,
completed_at: column.text,
description: column.text,
created_by: column.text,
completed_by: column.text,
completed: column.integer
},
{ indexes: { list: ['list_id'] } }
);
const lists = new TableV2({
created_at: column.text,
name: column.text,
owner_id: column.text
});
export const AppSchema = new Schema({
todos,
lists
});
export type Database = (typeof AppSchema)['types'];
export type TodoRecord = Database['todos'];
// OR:
// export type Todo = RowType<typeof todos>;
export type ListRecord = Database['lists'];
PowerSync can generate a Javascript version of your schema for you.
- Right-click on your instance in the PowerSync dashboard
- Select "Generate Client-Side Schema".
This will generate a schema definition in javascript that will give you a good starting point for building the rest of your schema.
Implement the SupabaseConnector
To tell PowerSync how to connect to the database we'll create a SupabaseConnector
, which implements the PowerSyncBackendConnector
interface with methods for fetching credentials and uploading data.
PowerSyncBackendConnector Interface
The Supabase Connector needs to implement the PowerSyncBackendConnector
interface (declared here), ensuring it can seamlessly
communicate with PowerSync for data synchronization.
The interface is straightforward and only requires two methods:
// from @journeyapps/powersync-sdk-common
export interface PowerSyncBackendConnector {
/** Allows the PowerSync client to retrieve an authentication token from your backend
* which is used to authenticate against the PowerSync service.
*
* This should always fetch a fresh set of credentials - don't use cached
* values.
*
* Return null if the user is not signed in. Throw an error if credentials
* cannot be fetched due to a network error or other temporary error.
*
* This token is kept for the duration of a sync connection.
*/
fetchCredentials: () => Promise<PowerSyncCredentials | null>
/** Upload local changes to the app backend.
*
* Use {@link AbstractPowerSyncDatabase.getCrudBatch} to get a batch of changes to upload.
*
* Any thrown errors will result in a retry after the configured wait period (default: 5 seconds).
*/
uploadData: (database: AbstractPowerSyncDatabase) => Promise<void>
}
Implement SupabaseConnector
In app/services/database/supabase.ts
, we'll add the two methods, and then export an object that implements the PowerSyncBackendConnector
interface.
// app/services/database/supabase.ts
import {
AbstractPowerSyncDatabase,
CrudEntry,
PowerSyncBackendConnector,
UpdateType,
PowerSyncCredentials
} from "@powersync/react-native"
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from "@supabase/supabase-js"
import Config from "../../config"
export const supabase = createClient(Config.supabaseUrl, Config.supabaseAnonKey, {
auth: {
persistSession: true, storage: AsyncStorage,
},
})
// This function fetches the session token from Supabase, it should return null if the user is not signed in, and the session token if they are.
async function fetchCredentials(): Promise<PowerSyncCredentials | null> {
const { data: { session }, error } = await supabase.auth.getSession()
if (error) {
throw new Error(`Could not fetch Supabase credentials: ${ error }`)
}
if (!session) {
return null
}
return {
endpoint: Config.powersyncUrl,
token: session.access_token ?? "",
expiresAt: session.expires_at
? new Date(session.expires_at * 1000)
: undefined
}
}
// Regexes for response codes indicating unrecoverable errors.
const FATAL_RESPONSE_CODES = [
/^22...$/, // Data Exception
/^23...$/, // Integrity Constraint Violation
/^42501$/, // INSUFFICIENT PRIVILEGE
]
// PowerSync will call this function to upload data to the backend
const uploadData: (database: AbstractPowerSyncDatabase) => Promise<void> = async (database) => {
const transaction = await database.getNextCrudTransaction()
if (!transaction) {
return
}
let lastOp: CrudEntry | null = null
try {
// Note: If transactional consistency is important, use database functions
// or edge functions to process the entire transaction in a single call.
for (const op of transaction.crud) {
lastOp = op
const table = supabase.from(op.table)
let result: any = null
switch (op.op) {
case UpdateType.PUT:
// eslint-disable-next-line no-case-declarations
const record = { ...op.opData, id: op.id }
result = await table.upsert(record)
break
case UpdateType.PATCH:
result = await table.update(op.opData).eq('id', op.id)
break
case UpdateType.DELETE:
result = await table.delete().eq('id', op.id)
break
}
if (result?.error) {
throw new Error(`Could not ${ op.op } data to Supabase error: ${ JSON.stringify(result) }`)
}
}
await transaction.complete()
} catch (ex: any) {
console.debug(ex)
if (typeof ex.code === 'string' && FATAL_RESPONSE_CODES.some((regex) => regex.test(ex.code))) {
/**
* Instead of blocking the queue with these errors,
* discard the (rest of the) transaction.
*
* Note that these errors typically indicate a bug in the application.
* If protecting against data loss is important, save the failing records
* elsewhere instead of discarding, and/or notify the user.
*/
console.error(`Data upload error - discarding ${ lastOp }`, ex)
await transaction.complete()
} else {
// Error may be retryable - e.g. network error or temporary server error.
// Throwing an error here causes this call to be retried after a delay.
throw ex
}
}
}
export const supabaseConnector: PowerSyncBackendConnector = {
fetchCredentials, uploadData,
}
Create the DatabaseContext
We need a single point of connection to the PowerSync instance that we can interact with throughout the app.
To achieve this we create a Database
singleton and provide a stable reference to that instance through our DatabaseContext
.
We'll check if the user is signed in or not before we initialize the database, as we need a valid session token to connect to the backend.
The DatabaseProvider
component wraps its children in both the DatabaseContext.Provider
, and also the PowerSyncContext.Provider
provided by the PowerSync SDK.
This is necessary because the hooks from the PowerSync SDK require the PowerSyncContext
to be present in the component tree, but we'll still need the DatabaseContext to get access to the powersync instance directly.
// app/services/database/database.tsx
import { SupabaseClient } from "@supabase/supabase-js"
import { useAuth } from "./use-auth"
import React, { PropsWithChildren, useEffect } from "react"
import {
AbstractPowerSyncDatabase,
PowerSyncContext,
PowerSyncDatabase,
} from "@powersync/react-native"
import { supabase, supabaseConnector } from "./supabase" // Adjust the path as needed
import { AppSchema } from "./schema" // Adjust the path as needed
export class Database {
// We expose the PowerSync and Supabase instances for easy access elsewhere in the app
powersync: AbstractPowerSyncDatabase
supabase: SupabaseClient = supabase
/**
* Initialize the Database class with a new PowerSync instance
*/
constructor() {
this.powersync = new PowerSyncDatabase({
database: {
dbFilename: "sqlite.db"
},
schema: AppSchema
})
}
/**
* Initialize the PowerSync instance and connect it to the Supabase backend.
* This will call `fetchCredentials` on the Supabase connector to get the session token.
* So if your database requires authentication, the user will need to be signed in before this is
* called.
*/
async init() {
await this.powersync.init()
await this.powersync.connect(supabaseConnector)
}
async disconnect() {
await this.powersync.disconnectAndClear()
}
}
const database = new Database()
// A context to provide our singleton to the rest of the app
const DatabaseContext = React.createContext<Database | null>(null)
export const useDatabase = () => {
const context: Database | null = React.useContext(DatabaseContext)
if (!context) {
throw new Error("useDatabase must be used within a DatabaseProvider")
}
return context
}
// Finally, we create a provider component that initializes the database and provides it to the app
export function DatabaseProvider<T>({ children }: PropsWithChildren<T>) {
const { user } = useAuth()
useEffect(() => {
if (user) {
database.init().catch(console.error)
}
}, [database, user])
return (
<DatabaseContext.Provider value={ database }>
<PowerSyncContext.Provider value={ database.powersync }>
{ children }
</PowerSyncContext.Provider>
</DatabaseContext.Provider>
)
}
Wrap the app in the DatabaseProvider
Now that we have our DatabaseProvider
set up, we can wrap our app in it to provide the database instance to the rest of the app.
Remember that the DatabaseProvider needs access to the users authentication state, so it should be wrapped in the AuthProvider
we created earlier.
// app/app.tsx
//... other imports
// Import the provider
import { DatabaseProvider } from "app/services/database/database"
// ...
function App(props: AppProps) {
// ...
return (
<AuthProvider>
{/* Add the Database Provider inside the AuthProvider */ }
<DatabaseProvider>
<SafeAreaProvider initialMetrics={ initialWindowMetrics }>
// ...
</SafeAreaProvider>
</DatabaseProvider>
</AuthProvider>
)
}
export default App
const $container: ViewStyle = {
flex: 1,
}
Managing Lists of Todos
In our app we'll want the ability to:
- fetch the list of all the todo lists from the database
- add a new todo list
- delete a todo list
To manage this we'll create the following:
- a
useLists
hook to encapsulate the logic for fetching and managing the lists - a
Lists
component to display the lists - an
AddList
component with a form to add a new list
The useLists
Hook
The useLists
hook will encapsulate the logic for fetching and managing the lists.
It will use:
useQuery
to fetch the lists from the database and watch for changesPowerSync.execute
to create and delete lists
useQuery
This hook is used to fetch data from the database and watch for changes. It will automatically re-fetch the data when the database changes.
We'll implement our own in a second, but Here's an example of what that looks like:
const { data: lists } = useQuery<ListItemRecord>(`
SELECT ${ LISTS_TABLE }.*,
COUNT(${ TODOS_TABLE }.id) AS total_tasks,
SUM(CASE WHEN ${ TODOS_TABLE }.completed = true THEN 1 ELSE 0 END) AS completed_tasks
FROM ${ LISTS_TABLE }
LEFT JOIN ${ TODOS_TABLE } ON ${ LISTS_TABLE }.id = ${ TODOS_TABLE }.list_id
GROUP BY ${ LISTS_TABLE }.id;
`);
You can write complex queries to fetch data from the database, and the hook will automatically re-fetch the data when the database changes.
This hook needs to be inside a PowerSyncContext.Provider
(or our DatabaseProvider
) to work.
PowerSync.execute()
This method is used to execute SQL queries against the database. We'll be using it here to create and delete lists.
const deleteList = useCallback(async (id: string) => {
console.log('Deleting list', id)
return powersync.execute(`DELETE FROM ${ LIST_TABLE } WHERE id = ?`, [id])
}, [powersync])
Disconnect PowerSync when we Sign Out
When we sign out, we should disconnect the PowerSync instance from the backend to prevent any further data synchronization, and wipe the local database to ensure that no data is left behind.
To do this we'll update our SignOutButton
to call powersync.disconnectAndClear()
before we sign out of supabase.
// app/components/SignOutButton.tsx
//...other imports
import { useDatabase } from "app/services/database/database"
// ...
export const SignOutButton = observer(function SignOutButton(props: SignOutButtonProps) {
// ...
const { signOut } = useAuth()
const { powersync } = useDatabase()
const handleSignOut = async () => { // make this async
await powersync.disconnectAndClear()
await signOut()
}
return (
<View style={ $styles }>
<Button text="Sign Out" onPress={ handleSignOut }/>
</View>
)
})
install expo-crypto
to generate UUIDs
Because PowerSync data is local-first, we can't rely on the database to generate auto-incrementing unique ids for us.
When we're offline, the server won't know how many items we've creating, or how many other devices are creating items.
So in this situation, the app needs to be responsible for generating unique ids for items locally.
We'll use the uuid()
SQLite method for this. It generates UUIDs using cryptographically secure
random values. This provides extra security and ensures that the generated UUIDs are unique.
Implementing the useLists
Hook
Now that we can generate random IDs, we can implement the useLists
hook:
// app/services/database/use-lists.ts
import { useQuery } from "@powersync/react-native"
import { useAuth } from "app/services/database/use-auth"
import { useCallback } from "react"
import { useDatabase } from "app/services/database/database"
import { LISTS_TABLE, ListRecord, TODOS_TABLE } from "app/services/database/schema"
// Extend the base type with the calculated fields from our query
export type ListItemRecord = ListRecord & { total_tasks: number; completed_tasks: number }
export const useLists = () => {
// Get the current user from the auth context
const { user } = useAuth()
// Get the database instance from the context
const { powersync } = useDatabase()
// List fetching logic here. You can modify it as per your needs.
const { data: lists } = useQuery<ListItemRecord>(`
SELECT ${ LISTS_TABLE }.*,
COUNT(${ TODOS_TABLE }.id) AS total_tasks,
SUM(CASE WHEN ${ TODOS_TABLE }.completed = true THEN 1 ELSE 0 END) as completed_tasks
FROM ${ LISTS_TABLE }
LEFT JOIN ${ TODOS_TABLE } ON ${ LISTS_TABLE }.id = ${ TODOS_TABLE }.list_id
GROUP BY ${ LISTS_TABLE }.id
`)
const createList = useCallback(async (name: string) => {
if (!user) {throw new Error("Can't add list -- user is undefined")}
return powersync.execute(
`
INSERT INTO ${ LISTS_TABLE }
(id, name, created_at, owner_id)
VALUES (uuid(), ?, ?, ?)`,
[name, new Date().toISOString(), user?.id],
)
}, [user, powersync])
const deleteList = useCallback(async (id: string) => {
console.log('Deleting list', id)
return powersync.execute(`DELETE
FROM ${ LISTS_TABLE }
WHERE id = ?`, [id])
}, [powersync])
return { lists, createList, deleteList }
}
Create the Lists and AddLists Components
We're going to need several components to view and manage our todo lists:
AddList
- displays form to add a new listLists
- displays the list of todo lists and allows the user to delete them
Let start by creating the components using the ignite CLI.
First create the AddList
and Lists
components:
npx ignite-cli generate component AddList
npx ignite-cli generate component Lists
Add Lists to the Welcome Screen
Add the Lists
component to the WelcomeScreen
// app/screens/WelcomeScreen.tsx
import { NativeStackScreenProps } from "@react-navigation/native-stack"
import { Lists, SignOutButton } from "app/components"
import { observer } from "mobx-react-lite"
import React, { FC } from "react"
import { ViewStyle } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
import { SignedInNavigatorParamList } from "../navigators"
import { colors } from "../theme"
interface WelcomeScreenProps
extends NativeStackScreenProps<SignedInNavigatorParamList, "Welcome"> {}
export const WelcomeScreen: FC<WelcomeScreenProps> = observer(function WelcomeScreen() {
return (
<SafeAreaView style={ $container }>
<Lists/>
<SignOutButton/>
</SafeAreaView>
)
})
const $container: ViewStyle = {
flex: 1,
backgroundColor: colors.palette.neutral300,
display: "flex",
justifyContent: "flex-start",
height: "100%",
flexDirection: "column",
}
Display Todo Lists in Lists
Now we can use our useLists
hook in app/components/Lists.tsx
to display a list of our todo lists -- we don't have any in our database yet, but we'll add some soon!
// app/components/Lists.tsx
import { NavigationProp, useNavigation } from "@react-navigation/native"
import { AddList, Icon, ListItem, Text } from "app/components"
import { AppStackParamList } from "app/navigators"
import { ListItemRecord, useLists } from "app/services/database/use-lists"
import React, { useCallback } from "react"
import { FlatList, TextStyle, View, ViewStyle } from "react-native"
import { colors, spacing } from "../theme"
export function Lists() {
// use our hook to fetch the lists
const { lists, deleteList } = useLists()
const navigation = useNavigation<NavigationProp<AppStackParamList>>()
// This function tells FlatList how to render each item
const renderItem = useCallback(({ item }: { item: ListItemRecord }) => {
return (
<ListItem
textStyle={ $listItemText }
onPress={ () => {
// Eventually this si where we'll navigate to the todo, but for now we'll just log the list name
console.log('Pressed: ', item.name)
} }
text={ `${ item.name }` }
RightComponent={
<View style={ $deleteListIcon }>
{/* Let users delete lists */}
<Icon icon={ "x" } onPress={ () => deleteList(item.id) }/>
</View>
}
/>
)
}, [])
return (
<View style={ $container }>
<Text preset={ "heading" }>Lists</Text>
<View style={ $card }>
<AddList/>
</View>
<View style={ [$list, $card] }>
<Text preset={ "subheading" }>Your Lists</Text>
<FlatList
style={ $listContainer }
// pass in our lists
data={ lists }
// pass in our renderItem function
renderItem={ renderItem }
keyExtractor={ (item) => item.id }
ItemSeparatorComponent={ () => <View style={ $separator }/> }
// show a message if the list is empty
ListEmptyComponent={ <Text style={ $emptyList }>No lists found</Text> }
/>
</View>
</View>
)
}
// STYLES
const $separator: ViewStyle = { height: 1, backgroundColor: colors.border }
const $emptyList: TextStyle = {
textAlign: "center",
color: colors.textDim,
opacity: 0.5,
padding: spacing.lg,
}
const $card: ViewStyle = {
shadowColor: colors.palette.neutral800,
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 0.35,
borderRadius: 8,
}
const $listContainer: ViewStyle = {
backgroundColor: colors.palette.neutral100,
paddingHorizontal: spacing.md,
height: "100%",
borderColor: colors.border,
borderWidth: 1,
}
const $list: ViewStyle = {
flex: 1,
marginVertical: spacing.md,
backgroundColor: colors.palette.neutral200,
padding: spacing.md,
}
const $container: ViewStyle = {
flex: 1,
display: "flex",
flexGrow: 1,
padding: spacing.md,
}
const $listItemText: TextStyle = {
height: 44,
width: 44,
}
const $deleteListIcon: ViewStyle = {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: 44,
marginVertical: spacing.xxs,
}
Create Todo Lists with AddList
Open app/components/AddList.tsx
and update the AddList
component to display a simple form to add a new list.
// app/components/AddList.tsx
import { Button, Text, TextField } from "app/components"
import { useLists } from "app/services/database/use-lists"
import { colors, spacing } from "app/theme"
import { observer } from "mobx-react-lite"
import React from "react"
import { Keyboard, TextStyle, View, ViewStyle } from "react-native"
/**
* Display a form to add a new list
*/
export const AddList = observer(function AddList() {
const [newListName, setNewListName] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
// we use the function from our hook to create a new list
const { createList } = useLists()
const handleAddList = React.useCallback(async () => {
if (!newListName) {
Keyboard.dismiss()
return
}
try {
await createList(newListName)
setNewListName("")
} catch (e: any) {
setError(`Failed to create list: ${ e?.message ?? "unknown error" }`)
} finally {
Keyboard.dismiss()
}
}, [createList, newListName])
return (
<View style={ $container }>
<Text preset={ "subheading" }>Add a List</Text>
<View style={ $form }>
<TextField
placeholder="Enter a list name"
containerStyle={ $textField }
inputWrapperStyle={ $textInput }
onChangeText={ setNewListName }
value={ newListName }
onSubmitEditing={ handleAddList }
/>
<Button text="Add List" style={ $button } onPress={ handleAddList }/>
</View>
{ error && <Text style={ $error }>{ error }</Text> }
</View>
)
})
const $container: ViewStyle = {
padding: spacing.md,
backgroundColor: colors.palette.neutral200,
}
const $form: ViewStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
}
const $textField: ViewStyle = {
flex: 1,
}
const $textInput: ViewStyle = {
backgroundColor: colors.palette.neutral100,
}
const $button: ViewStyle = {
marginHorizontal: spacing.xs,
padding: 0,
paddingHorizontal: spacing.xs,
paddingVertical: 0,
minHeight: 44,
}
const $error: TextStyle = {
color: colors.error,
marginTop: spacing.sm,
}
Checking In
By this point you should be able to:
- add new Todo lists,
- see a list of all the Todo lists,
- delete Todo lists from the list of lists.
This is a good time to commit your changes!
Viewing and Editing Individual TodoLists
To view and edit todos inside a list, we'll want to create a new Screen, and add it to the navigator so we can navigate to it.
Create the TodoList Screen
First lets create a new screen called TodoList
.
npx ignite-cli generate screen TodoList
This will eventually display the todos for a list. For now we'll just leave it as-is.
Update the AppNavigator to include the TodoList
screen
Open app/navigators/SignedInNavigator.tsx
and add the TodoList
screen to the navigator.
-
Find the
TodoList
screen inAppStackParamList
, and update it to take alistId
parameter.// app/navigators/AppNavigator.tsx
export type AppStackParamList = {
Welcome: undefined
Auth: undefined
TodoList: { listId: string } // add this line
// IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST
} -
Wrap
WelcomeScreen
in a fragment (<>...</>
) and move theTodoList
screen inside the fragment.Because the fragment is wrapped in the conditional, it will only be rendered if the user is signed in.
// ...
const AppStack = observer(function AppStack() {
// Fetch the user from the auth context
const { signedIn } = useAuth()
return (
<Stack.Navigator screenOptions={ { headerShown: false, navigationBarColor: colors.background } }>
<Stack.Screen name={ "Auth" } component={ AuthScreen }/>
{ signedIn ? (
<>
<Stack.Screen name="Welcome" component={ Screens.WelcomeScreen }/>
<Stack.Screen name="TodoList" component={ Screens.TodoListScreen }/>
</>
) : null }
{/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */ }
</Stack.Navigator>
)
})
export const AppNavigator = observer(function AppNavigator(props: NavigationProps) {
// ...
}) -
Open
app/screens/TodoListScreen.tsx
and update the screen to receive thelistId
parameter -- for now we'll just display it to make sure we got it.// app/screens/TodoListScreen.tsx
// ...
export const TodoListScreen: FC<TodoListScreenProps> = function TodoListScreen({
navigation,
// We get the listId from the route params
route: { params: {listId} }
}) {
return (
<Screen style={ $root } preset="scroll" safeAreaEdges={ ["top"] }>
<Pressable onPress={ () => navigation.goBack() }>
<Icon icon={ "back" } size={ 50 }/>
</Pressable>
<Text preset={ "heading" } text={ listId }/>
</Screen>
)
}
const $root: ViewStyle = {
flex: 1,
}
const $backButton: ViewStyle = {
height: 44,
}
Update Lists
so touching a list navigated to TodoListScreen
and passes the listId
param
Now we can update the Lists
component to navigate to the TodoList
screen when a list is pressed.
// app/components/Lists.tsx
// ... other imports
import { NavigationProp, useNavigation } from "@react-navigation/native"
import { AppStackParamList } from "app/navigators"
export function Lists() {
const { lists, deleteList } = useLists()
// We use the root param list, because this component might be reusing in other screens/navigators
const navigation = useNavigation<NavigationProp<AppStackParamList>>()
const renderItem = useCallback(({ item }: { item: ListItemRecord }) => {
return <ListItem
// ... other props
// Navigate to the TodoList screen, passing the `listId` as a parameter
onPress={ () => {
navigation.navigate("TodoList", { listId: item.id })
} }
/>
}, [])
return (
//... component body
)
}
Implement a useList
hook to view and manage Todos for a List
Now that we can navigate to the TodoList
screen, we can start fetching and managing the todos for a list.
Once again we'll create a hook to gather together the methods we'll likely use together.
The hook will take a listId
as a parameter, and return the list, the todos for that list, and functions to add and remove todos.
We are using usePowerSyncQuery
to fetch the list and usePowerSyncWatchedQuery
to fetch the todos, because the list itself won't change often, so we only need to watch the todos for changes.
// app/services/database/use-list.ts
import { useQuery } from "@powersync/react-native"
import { useDatabase } from "app/services/database/database"
import { LISTS_TABLE, ListRecord, TODOS_TABLE, TodoRecord } from "app/services/database/schema"
import { useAuth } from "app/services/database/use-auth"
import { useCallback } from "react"
export function useList(listId: string) {
const { user } = useAuth()
const { powersync } = useDatabase()
const { data: listRecords } = useQuery<ListRecord>(`
SELECT *
FROM ${ LISTS_TABLE }
WHERE id = ?
`, [listId])
// we only expect one list record
const list = listRecords[0]
const { data: todos } = useQuery<TodoRecord>(`
SELECT *
FROM ${ TODOS_TABtLE }
WHERE list_id = ?
`, [listId])
const addTodo = useCallback(async (description: string): Promise<{ error: string | null }> => {
if (!user) {
throw new Error("Can't add todo -- user is undefined")
}
try {
await powersync.execute(
`INSERT INTO ${ TODOS_TABLE }
(id, description, created_at, list_id, created_by, completed)
VALUES (uuid(), ?, ?, ?, ?, ?)`,
[description, new Date().toISOString(), listId, user?.id, 0],
)
return { error: null }
} catch (error: any) {
return { error: `Error adding todo: ${ error?.message }` }
}
}, [user, powersync, listId])
const removeTodo = useCallback(async (id: string): Promise<{ error: string | null }> => {
try {
await powersync.execute(`DELETE
FROM ${ TODOS_TABLE }
WHERE id = ?`, [id])
return { error: null }
} catch (error: any) {
console.error("Error removing todo", error)
return { error: `Error removing todo: ${ error?.message }` }
}
}, [
powersync,
])
const setTodoCompleted = useCallback(async (id: string, completed: boolean): Promise<{ error: string | null }> => {
const completedAt = completed ? new Date().toISOString() : null
const completedBy = completed ? user?.id : null
try {
await powersync.execute(`
UPDATE ${ TODOS_TABLE }
SET completed = ?, completed_at = ?, completed_by = ?
WHERE id = ?
`, [completed, completedAt, completedBy, id])
return { error: null }
} catch (error: any) {
console.error('Error toggling todo', error)
return { error: `Error toggling todo: ${ error?.message }` }
}
}, [powersync])
return { list, todos, addTodo, removeTodo, setTodoCompleted }
}
Implement TodoListScreen
to Display and Edit Todos in a List
Now in our TodoList
screen we can use the useList
hook to fetch the todos for a list:
A lot of this should be familiar by now, we're using the useList
hook to fetch the list and todos, and then rendering them in a FlatList.
// app/screens/TodoListScreen.tsx
import { Button, Icon, ListItem, Screen, Text, TextField } from "app/components"
import { SignedInNavigatorScreenProps } from "app/navigators"
import { TodoRecord } from "app/services/database/schema"
import { useList } from "app/services/database/use-list"
import { colors, spacing } from "app/theme"
import React, { FC, useCallback } from "react"
import { FlatList, Pressable, TextStyle, View, ViewStyle } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
interface TodoListScreenProps extends SignedInNavigatorScreenProps<"TodoList"> {}
export const TodoListScreen: FC<TodoListScreenProps> = function TodoListScreen({
navigation,
route: { params: { listId } },
}) {
// We use the hook to get the list and todos for the list
const { list, todos, addTodo, removeTodo, setTodoCompleted } = useList(listId)
// State for managing the new todo input and errors
const [newTodo, setNewTodo] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
// We wrap the addTodo from the hook with a bit of error handling
const handleAddTodo = useCallback(async () => {
const { error } = await addTodo(newTodo)
if (error) {
setError(error)
return
}
setNewTodo("")
}, [newTodo])
// And do the same for removeTodo
const handleRemoveTodo = useCallback(async (id: string) => {
const { error } = await removeTodo(id)
if (error) {
setError(error)
}
}, [removeTodo, setError])
// We'll use the ListItem component to display each todo, as we did with the lists
const renderItem = useCallback(({ item }: { item: TodoRecord }) => {
return <ListItem
containerStyle={ $listItemContainer }
textStyle={ [$listItemText, item.completed && $strikeThrough] }
text={ `${ item.description }` }
RightComponent={ (
<Pressable style={ $deleteIcon }>
<Icon icon={ "x" } onPress={ () => handleRemoveTodo(item.id) }/>
</Pressable>) }
onPress={ () => setTodoCompleted(item.id, !item.completed) }
/>
}, [
handleRemoveTodo,
])
return (
<Screen style={ $root } preset="fixed">
<SafeAreaView style={ $header } edges={ ["top"] }>
<Pressable onPress={ () => navigation.goBack() }>
<Icon icon={ "back" } size={ 44 }/>
</Pressable>
<Text style={ $listName } preset={ "heading" } text={ list?.name }/>
</SafeAreaView>
<View style={ $addTodoContainer }>
<Text preset={ "subheading" }>Add a list</Text>
<View style={ $form }>
<TextField
placeholder="New todo..."
containerStyle={ $textField }
inputWrapperStyle={ $textInput }
onChangeText={ setNewTodo }
value={ newTodo }/>
<Button text="ADD" style={ $button } onPress={ handleAddTodo }/>
</View>
{ error && <Text style={ $error }>{ error }</Text> }
</View>
<View style={ $container }>
<FlatList
data={ todos }
renderItem={ renderItem }
ItemSeparatorComponent={ () => <View style={ $separator }/> }
ListEmptyComponent={ <Text style={ $emptyList }>List is Empty</Text> }
/>
</View>
</Screen>
)
}
const $root: ViewStyle = {
flex: 1,
}
const $listItemContainer: ViewStyle = {
alignItems: "center",
}
const $strikeThrough: TextStyle = { textDecorationLine: "line-through" }
const $form: ViewStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
}
const $separator: ViewStyle = { height: 1, backgroundColor: colors.border }
const $emptyList: TextStyle = {
color: colors.textDim,
opacity: 0.5,
padding: spacing.lg,
fontSize: 24,
}
const $textField: ViewStyle = {
flex: 1,
}
const $textInput: ViewStyle = {
backgroundColor: colors.palette.neutral100,
}
const $button: ViewStyle = {
marginHorizontal: spacing.xs,
padding: 0,
paddingHorizontal: spacing.xs,
paddingVertical: 0,
}
const $addTodoContainer: ViewStyle = {
padding: spacing.md,
backgroundColor: colors.palette.neutral300,
}
const $header: ViewStyle = {
display: "flex",
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.palette.secondary200,
paddingBottom: spacing.md,
}
const $listName: TextStyle = {
marginLeft: spacing.sm,
flex: 1,
}
const $error: TextStyle = {
color: colors.error,
marginTop: spacing.sm,
}
const $container: ViewStyle = {
padding: spacing.md,
}
const $listItemText: TextStyle = {
height: 44,
verticalAlign: "middle"
}
const $deleteIcon: ViewStyle = {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: 44,
marginVertical: spacing.xxs,
}
Checking In
At this point you should be able to:
- Touch a list in the
Lists
component and navigate to theTodoList
screen - See the list of todos in the list on the
TodoList
screen - Add and remove todos from the list
- Toggle todos completed status by tapping them
Congratulations!
Now you have all the tools you need use PowerSync and Supabase to build a local-first app with real-time sync!