Skip to main content

New Recipe

PowerSync and Supabase for Local-First Data Management

Published on March 22nd, 2024 by Trevor Coleman

View recipe

Latest Ignite Release

View on Github

Proven 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.

Animation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation Image

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,
}


updated 7 days ago
Animation Image

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!

Animation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation ImageAnimation Image