New Recipe
PowerSync and Supabase for Local-First Data Management
Published on March 22nd, 2024 by Trevor Coleman
View recipeLatest Ignite Release
View on GithubProven Recipes for your React Native apps
Starting from scratch doesn’t always make sense. That’s why we made the Ignite Cookbook for React Native – an easy way for developers to browse and share code snippets (or “recipes”) that actually work.
Spin Up Your App In Record Time
Stop reinventing the wheel on every project. Use the Ignite CLI to get your app started. Then, hop over to the Ignite Cookbook for React Native to browse for things like libraries in “cookie cutter” templates that work for almost any project. It’s the fastest way to get a React Native app off the ground.
Find Quality Code When You Need It
The popular forum sites are great for finding code until you realize it’s based on an old version of React Native. Ignite Cookbook is a place for recipes that work as of the time they’re published – meaning, it worked when it was posted. And if it ever goes out of date, we’ll make sure the community knows on what version it was last working.
npx ignite-cli@latest new PowerSyncIgnite --remove-demo --workflow=cng --yes npx expo install \ @journeyapps/powersync-sdk-react-native \ @journeyapps/react-native-quick-sqlite npx expo install \ react-native-fetch-api \ react-native-polyfill-globals \ react-native-url-polyfill \ text-encoding \ web-streams-polyfill@^3.2.1 \ base-64 \ @azure/core-asynciterator-polyfill \ react-native-get-random-values yarn add -D @babel/plugin-transform-async-generator-functions npx expo install @supabase/supabase-js npx expo install @react-native-async-storage/async-storage import "react-native-polyfill-globals/auto" import "@azure/core-asynciterator-polyfill" /** @type {import('@babel/core').TransformOptions['plugins']} */ const plugins = [ //... other plugins // success-line '@babel/plugin-transform-async-generator-functions', // <-- Add this /** NOTE: This must be last in the plugins @see https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation/#babel-plugin */ "react-native-reanimated/plugin", ] { "expo": { "plugins": [ // ... [ "expo-build-properties", { "ios": { "newArchEnabled": false, "flipper": false }, "android": { "newArchEnabled": false, // success-line "networkInspector": false } } ] //... ] //... } } // `app/config/config.base.ts`: // update the interface to include the new properties export interface ConfigBaseProps { // Existing config properties // success-line supabaseUrl: string // success-line supabaseAnonKey: string } // Add the new properties to the config object const BaseConfig: ConfigBaseProps = { // Existing config values // success-line supabaseUrl: '<<YOUR_SUPABASE_URL>>', // success-line supabaseAnonKey: '<<YOUR_SUPABASE_ANON_KEY>>', } // 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, }, }) // 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 } // app/app.tsx // ...other imports // success-line import { AuthProvider } from "app/services/database/use-auth" // ... function App(props: AppProps) { // ... return ( // success-line <AuthProvider> <SafeAreaProvider> {/* ... */ } </SafeAreaProvider> // success-line </AuthProvider> ) } npx ignite-cli generate screen Auth // 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, } npx ignite-cli generate component SignOutButton // 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, } // app/screens/WelcomeScreen.tsx import { NativeStackScreenProps } from "@react-navigation/native-stack" import { Lists, SignOutButton } from "app/components" import { DatabaseProvider } from "app/services/database/database" 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" export const WelcomeScreen: FC<WelcomeScreenProps> = observer(function WelcomeScreen() { return ( <DatabaseProvider> <SafeAreaView style={ $container }> <SignOutButton/> </SafeAreaView> </DatabaseProvider> ) }) const $container: ViewStyle = { flex: 1, backgroundColor: colors.palette.neutral300, display: "flex", justifyContent: "flex-start", height: "100%", flexDirection: "column", } // app/navigators/AppNavigator.tsx //... const AppStack = observer(function AppStack() { // Fetch the user from the auth context // success-line // success-line 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 */ } // success-line { signedIn // success-line ? <Stack.Screen name="Welcome" component={ Screens.WelcomeScreen }/> // success-line : null // success-line } <Stack.Screen name="Auth" component={ Screens.AuthScreen }/> {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */ } </Stack.Navigator> ) }) // ... // app/config/config.base.ts: // update the interface to include the new properties export interface ConfigBaseProps { // Existing config properties supabaseUrl: string supabaseAnonKey: string // success-line 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>>', // success-line powersyncUrl: '<<YOUR_POWER_SYNC_URL>>', } // app/services/database/schema.ts import { Column, ColumnType, Index, IndexedColumn, Schema, Table } from '@journeyapps/powersync-sdk-react-native' export const TODO_TABLE = 'todos' export const LIST_TABLE = 'lists' export interface ListRecord { id: string name: string created_at: string owner_id?: string } export interface TodoRecord { id: string created_at: string completed: boolean description: string completed_at?: string created_by: string completed_by?: string list_id: string photo_id?: string } export const AppSchema = new Schema([ new Table({ name: 'todos', columns: [ new Column({ name: 'list_id', type: ColumnType.TEXT }), new Column({ name: 'created_at', type: ColumnType.TEXT }), new Column({ name: 'completed_at', type: ColumnType.TEXT }), new Column({ name: 'description', type: ColumnType.TEXT }), new Column({ name: 'completed', type: ColumnType.INTEGER }), new Column({ name: 'created_by', type: ColumnType.TEXT }), new Column({ name: 'completed_by', type: ColumnType.TEXT }) ], indexes: [ new Index({ name: 'list', columns: [new IndexedColumn({ name: 'list_id' })] }) ] }), new Table({ name: 'lists', columns: [ new Column({ name: 'created_at', type: ColumnType.TEXT }), new Column({ name: 'name', type: ColumnType.TEXT }), new Column({ name: 'owner_id', type: ColumnType.TEXT }) ] }), ]) // 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> } // app/services/database/supabase.ts import { AbstractPowerSyncDatabase, CrudEntry, PowerSyncBackendConnector, UpdateType, PowerSyncCredentials } from "@journeyapps/powersync-sdk-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, } // 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, RNQSPowerSyncDatabaseOpenFactory, } from "@journeyapps/powersync-sdk-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() { const factory = new RNQSPowerSyncDatabaseOpenFactory({ schema: AppSchema, dbFilename: "sqlite.db", }) this.powersync = factory.getInstance() } /** * 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> ) } // app/app.tsx //... other imports // success-line // Import the provder // success-line import { DatabaseProvider } from "app/services/database/database" // ... function App(props: AppProps) { // ... return ( <AuthProvider> // success-line {/* Add the Database Provider inside the AuthProvider */ } // success-line <DatabaseProvider> <SafeAreaProvider initialMetrics={ initialWindowMetrics }> // ... </SafeAreaProvider> // success-line </DatabaseProvider> </AuthProvider> ) } export default App const $container: ViewStyle = { flex: 1, } const lists = usePowerSyncWatchedQuery<ListItemRecord>(` SELECT ${ LIST_TABLE }.*, COUNT(${ TODO_TABLE }.id) AS total_tasks, SUM(CASE WHEN ${ TODO_TABLE }.completed = true THEN 1 ELSE 0 END) AS completed_tasks FROM ${ LIST_TABLE } LEFT JOIN ${ TODO_TABLE } ON ${ LIST_TABLE }.id = ${ TODO_TABLE }.list_id GROUP BY ${ LIST_TABLE }.id; `); const deleteList = useCallback(async (id: string) => { console.log('Deleting list', id) return powersync.execute(`DELETE FROM ${ LIST_TABLE } WHERE id = ?`, [id]) }, [powersync]) // app/components/SignOutButton.tsx //...other imports import { useDatabase } from "app/services/database/database" // ... export const SignOutButton = observer(function SignOutButton(props: SignOutButtonProps) { // ... const { signOut } = useAuth() // success-line const { powersync } = useDatabase() // success-line const handleSignOut = async () => { // make this async // success-line await powersync.disconnectAndClear() await signOut() } return ( <View style={ $styles }> <Button text="Sign Out" onPress={ handleSignOut }/> </View> ) }) npx expo add expo-crypto // app/services/database/use-lists.ts import { usePowerSyncWatchedQuery } from "@journeyapps/powersync-sdk-react-native" import { useAuth } from "app/services/database/use-auth" import { useCallback } from "react" import { useDatabase } from "app/services/database/database" import { LIST_TABLE, ListRecord, TODO_TABLE } from "app/services/database/schema" import { randomUUID } from 'expo-crypto' // 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 lists = usePowerSyncWatchedQuery<ListItemRecord>(` SELECT ${ LIST_TABLE }.*, COUNT(${ TODO_TABLE }.id) AS total_tasks, SUM(CASE WHEN ${ TODO_TABLE }.completed = true THEN 1 ELSE 0 END) as completed_tasks FROM ${ LIST_TABLE } LEFT JOIN ${ TODO_TABLE } ON ${ LIST_TABLE }.id = ${ TODO_TABLE }.list_id GROUP BY ${ LIST_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 ${ LIST_TABLE } (id, name, created_at, owner_id) VALUES (?, ?, ?, ?)`, [randomUUID(), name, new Date().toISOString(), user?.id], ) }, [user, powersync]) const deleteList = useCallback(async (id: string) => { console.log('Deleting list', id) return powersync.execute(`DELETE FROM ${ LIST_TABLE } WHERE id = ?`, [id]) }, [powersync]) return { lists, createList, deleteList } } npx ignite-cli generate component AddList npx ignite-cli generate component Lists // 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", } // 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, } // 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, } npx ignite-cli generate screen TodoList // app/navigators/AppNavigator.tsx export type AppStackParamList = { Welcome: undefined Auth: undefined // success-line TodoList: { listId: string } // add this line // IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST } // ... 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 }/> // success-line { signedIn ? ( // success-line <> // success-line <Stack.Screen name="Welcome" component={ Screens.WelcomeScreen }/> // success-line <Stack.Screen name="TodoList" component={ Screens.TodoListScreen }/> // success-line </> // success-line ) : null } {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */ } </Stack.Navigator> ) }) export const AppNavigator = observer(function AppNavigator(props: NavigationProps) { // ... }) // app/screens/TodoListScreen.tsx // ... export const TodoListScreen: FC<TodoListScreenProps> = function TodoListScreen({ navigation, // success-line // We get the listId from the route params // success-line route: { params: {listId} } }) { return ( <Screen style={ $root } preset="scroll" safeAreaEdges={ ["top"] }> // success-line <Pressable onPress={ () => navigation.goBack() }> <Icon icon={ "back" } size={ 50 }/> </Pressable> // success-line <Text preset={ "heading" } text={ listId }/> </Screen> ) } const $root: ViewStyle = { flex: 1, } const $backButton: ViewStyle = { height: 44, } // 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 // success-line 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={ () => { // success-line navigation.navigate("TodoList", { listId: item.id }) } } /> }, []) return ( //... component body ) } // app/services/database/use-list.ts import { usePowerSyncQuery, usePowerSyncWatchedQuery } from "@journeyapps/powersync-sdk-react-native" import { useDatabase } from "app/services/database/database" import { LIST_TABLE, ListRecord, TODO_TABLE, TodoRecord } from "app/services/database/schema" import { useAuth } from "app/services/database/use-auth" import { useCallback } from "react" import { randomUUID } from 'expo-crypto' export function useList(listId: string) { const { user } = useAuth() const { powersync } = useDatabase() const listRecords = usePowerSyncQuery<ListRecord>(` SELECT * FROM ${ LIST_TABLE } WHERE id = ? `, [listId]) // we only expect one list record const list = listRecords[0] const todos = usePowerSyncWatchedQuery<TodoRecord>(` SELECT * FROM ${ TODO_TABLE } 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 ${ TODO_TABLE } (id, description, created_at, list_id, created_by, completed) VALUES (?, ?, ?, ?, ?, ?)`, [randomUUID(), 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 ${ TODO_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 ${ TODO_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 } } // 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, }
Backed By A Community of React Native Experts
The Ignite Cookbook isn’t just a random group of code snippets. It’s a curated collection of usable code samples that the Infinite Red team’s used in their own React Native projects. Having worked with some of the biggest clients in the tech industry, we know a thing or two about keeping our code to a high standard. You can code confidently!
Freshly added to the cookbook
New
PowerSync and Supabase for Local-First Data Management
Published on March 22nd, 2024 by Trevor Coleman
Enforcing JS/TS Import Order
Published on February 29th, 2024 by Mark Rickert
Requiring Hardware Features with Expo
Published on February 28th, 2024 by Mark Rickert
Remove MobX-State-Tree
Published on February 5th, 2024 by Justin Poliachik
View all recipes