In this post, I discuss how I have set up SQLite in my Expo app. I utilize hooks and functional components to make my code reusable and modular.
I really like this post, which goes into great detail about using SQLite in a non-Expo setting: https://brucelefebvre.com/blog/2020/05/03/react-native-offline-first-db-with-sqlite-hooks/
This post assumes you have a working Expo React Native project, and that you are somewhat familiar with contexts, hooks, and state in React Native. I will show code that will manage a list of users, using a database, hooks with state, and a context.
To do the initial setup for SQLite, run:
expo install expo-sqlite
Overview
- Set up file for all of the DB queries
- Set up a
hook
to initialize the database - Set up a
context
for managing users - Use the
context
in components
Set up DB Queries
I like to keep my queries in a single file, this way, if I ever want to move off of SQLite, or mock out the DB for tests, I can swap out a single file.
Below is my code that will create our db tables, initialize the users db, get users, insert users. In addition, there is function to drop the db tables, which is helpful during development and test.
import React from 'react'
import * as SQLite from "expo-sqlite"
const db = SQLite.openDatabase('db.db')
const getUsers = (setUserFunc) => {
db.transaction(
tx => {
tx.executeSql(
'select * from users',
[],
(_, { rows: { _array } }) => {
setUserFunc(_array)
}
);
},
(t, error) => { console.log("db error load users"); console.log(error) },
(_t, _success) => { console.log("loaded users")}
);
}
const insertUser = (userName, successFunc) => {
db.transaction( tx => {
tx.executeSql( 'insert into users (name) values (?)', [userName] );
},
(t, error) => { console.log("db error insertUser"); console.log(error);},
(t, success) => { successFunc() }
)
}
const dropDatabaseTablesAsync = async () => {
return new Promise((resolve, reject) => {
db.transaction(tx => {
tx.executeSql(
'drop table users',
[],
(_, result) => { resolve(result) },
(_, error) => { console.log("error dropping users table"); reject(error)
}
)
})
})
}
const setupDatabaseAsync = async () => {
return new Promise((resolve, reject) => {
db.transaction(tx => {
tx.executeSql(
'create table if not exists users (id integer primary key not null, name text);'
);
},
(_, error) => { console.log("db error creating tables"); console.log(error); reject(error) },
(_, success) => { resolve(success)}
)
})
}
const setupUsersAsync = async () => {
return new Promise((resolve, _reject) => {
db.transaction( tx => {
tx.executeSql( 'insert into users (id, name) values (?,?)', [1, "john"] );
},
(t, error) => { console.log("db error insertUser"); console.log(error); resolve() },
(t, success) => { resolve(success)}
)
})
}
export const database = {
getUsers,
insertUser,
setupDatabaseAsync,
setupUsersAsync,
dropDatabaseTablesAsync,
}
Some things to note about the code
const db = SQLite.openDatabase('db.db')
opens the database nameddb.db
.- The
dropDatabaseTablesAsync
,setupDatabaseAsync
, andsetupUsersAsync
are asynchronous functions that return a promise. This means that we can call those functions withawait
. We will call these functions while showing the splash screen, waiting for the tasks to be finished before we move on. - The last 2 parameters of the
db.transaction
are theerror
andsuccess
functions, which are called when the transaction is complete. We use the promiseresolve
andreject
functions here. I found this article helpful: https://medium.com/@theflyingmantis/async-await-react-promise-testing-a0d454b5461b - The other functions aren’t asynchronous because we don’t really need to wait for them to finish.
- For
getUsers
, we pass in a function that takes the array that the query returns as its parameter. We will pass in a function that can take the users from the query and set the state. - For
insertUser
, we pass in asuccessFunc
that will be called after the insert has happened. In our case, we are passing in the function to refresh the users from the database. This way we know that our state will reflect what is in the database. - At the bottom of the file, we are exporting the functions so we can use them in other components.
The useDatabase Hook
When the app starts up, we want to set up the database tables if they haven’t already been setup, and insert some initial data. When working in dev, we may want to drop the existing tables to start clean, so we include a function call for that, which we can comment out in prod.
Here is the code, we put this file in the hooks
directory. Hooks are a convenient location for code that can be called from functional components.
// force the state to clear with fast refresh in Expo
// @refresh reset
import React, {useEffect} from 'react';
import {database} from '../components/database'
export default function useDatabase() {
const [isDBLoadingComplete, setDBLoadingComplete] = React.useState(false);
useEffect(() => {
async function loadDataAsync() {
try {
await database.dropDatabaseTablesAsync()
await database.setupDatabaseAsync()
await database.setupUsersAsync()
setDBLoadingComplete(true);
} catch (e) {
console.warn(e);
}
}
loadDataAsync();
}, []);
return isDBLoadingComplete;
}
Some notes on this code:
- The component manages its own state (
isDBLoadingComplete
) to indicate when the database loading is complete. - We put the code within the
useEffect
function so that it is called when the component is loaded. By including the[]
as the second parameter, we only call this function on the initial render. - The
await
calls will run in order, and will only move on to the next line when the function has returned. - After all of the database setup functions are called and have returned, we will set the state to indicate that the loading is complete. We will be watching for this state value in the
App.js
to know when we can hide the splash screen and show the homescreen. - The
// @refresh reset
comment will force the state to be cleared when the app refreshes in Expo.
Initializing the Database
We only want to initialize the database when the application first starts, and since the app can’t really work without the database, we should show the splash screen until the initialization is done. We can do this in the App.js
file.
Since the useEffect
function within useDatabase
is asynchronous, we can’t guarantee that it will return right away. We set it up to track a state flag indicating when it is done, so the code in our App.js
will watch for that flag.
Here is the relevant code in the App.js
.
import React from 'react';
import { View } from 'react-native';
import * as SplashScreen from 'expo-splash-screen';
import useDatabase from './hooks/useDatabase'
import useCachedResources from './hooks/useCachedResources';
export default function App(props) {
SplashScreen.preventAutoHideAsync(); //don't let the splash screen hide
const isLoadingComplete = useCachedResources();
const isDBLoadingComplete = useDatabase();
if (isLoadingComplete && isDBLoadingComplete) {
SplashScreen.hideAsync();
return (
<View>
...Render the app stuff here...
</View>
);
} else {
return null;
}
}
Some notes in the code
- Notice we hide the splash screen with
SplashScreen.hideAsync()
only when both loading flags are true. - The
useCachedResources
is part of the Expo boilerplate. - The App may return
null
a few times before the database and cached resources are done loading.
Context for user data
The app will need to have access the user data from multiple screens.
In the example code, we have 2 tabs:
- HomeScreen – showing the list of users, with an input field to add users.
- UserListScreen – showing the list of users
Both tabs need to be updated with the new user list when a user is inserted. To do this, we can store the user data and functions in a context
: https://reactjs.org/docs/context.html
Contexts shouldn’t be used for all data, but if you need to share data across many components, sometimes deeply nested, it might be a good solution.
The code below was inspired by this post: https://www.codementor.io/@sambhavgore/an-example-use-context-and-hooks-to-share-state-between-different-components-sgop6lnrd
// force the state to clear with fast refresh in Expo
// @refresh reset
import React, { useEffect, createContext, useState } from 'react';
import {database} from '../components/database'
export const UsersContext = createContext({});
export const UsersContextProvider = props => {
// Initial values are obtained from the props
const {
users: initialUsers,
children
} = props;
// Use State to store the values
const [users, setUsers] = useState(initialUsers);
useEffect(() => {
refreshUsers()
}, [] )
const addNewUser = userName => {
return database.insertUser(userName, refreshUsers)
};
const refreshUsers = () => {
return database.getUsers(setUsers)
}
// Make the context object:
const usersContext = {
users,
addNewUser
};
// pass the value in provider and return
return <UsersContext.Provider value={usersContext}>{children}</UsersContext.Provider>;
};
Some notes on the code
- This will create the
Context
andProvider
. We could have also created aConsumer
, but since we are using theuseContext
function, we don’t need it. - Within the
addNewUser
andrefreshUsers
functions, we are making our database calls.- In
refreshUsers
we are sending thesetUsers
function, which will allow the query to set our local state. - In
addNewUser
we are sending therefreshUsers
function to refresh our state from the database.
- In
- We have a
useEffect
call to instantiate the users list from the database. We only call this function on the first render. - We are set up to take an initial state through props when we create the
UsersContextProvider
, but those values are quickly overwritten with theuseEffect
call. I left the code here for reference.
Setting up the Provider
In order to make the context available to the HomeScreen
and UserListScreen
, we need to wrap a common parent component in the context Provider. This will be done in the App.js
.
import {UsersContextProvider} from './context/UsersContext'
.
.
.
<UsersContextProvider>
< parent of HomeScreen and UserListScreen components goes here>
</UsersContextProvider>
Here is the complete App.js
file, which is mostly boilerplate from initializing the Expo app.
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import React from 'react';
import { Platform, StatusBar, StyleSheet, View } from 'react-native';
import * as SplashScreen from 'expo-splash-screen';
import useDatabase from './hooks/useDatabase'
import useCachedResources from './hooks/useCachedResources';
import {UsersContextProvider} from './context/UsersContext'
import BottomTabNavigator from './navigation/BottomTabNavigator';
import LinkingConfiguration from './navigation/LinkingConfiguration';
const Stack = createStackNavigator();
export default function App(props) {
SplashScreen.preventAutoHideAsync();
const isLoadingComplete = useCachedResources();
const isDBLoadingComplete = useDatabase();
if (isLoadingComplete && isDBLoadingComplete) {
SplashScreen.hideAsync();
return (
<View style={styles.container}>
{Platform.OS === 'ios' && <StatusBar barStyle="dark-content" />}
<UsersContextProvider>
<NavigationContainer linking={LinkingConfiguration} >
<Stack.Navigator>
<Stack.Screen name="Root" component={BottomTabNavigator} />
</Stack.Navigator>
</NavigationContainer>
</UsersContextProvider>
</View>
);
} else {
return null;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
}
});
Accessing the context from a component
To access the context, we use the useContext
function, passing in our desired context. In the case of the UserListScreen.js
, we just need the users
, which we then render within our return
call.
import React, {useContext} from 'react';
import {StyleSheet, Text} from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import {UsersContext } from '../context/UsersContext'
export default function UserListScreen() {
const { users } = useContext(UsersContext)
return (
<ScrollView style={styles.container}>
<Text>Here is our list of users</Text>
{users.map((user) => (
<Text key={user.id}>{user.name}</Text>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fafafa',
},
});
We do something similar in the HomeScreen.js
, but we also import the function to add a new name: addNewUser
.
import React, {useState, useContext} from 'react';
import { StyleSheet, Button, Text, TextInput, View } from 'react-native';
import {UsersContext} from '../context/UsersContext'
export default function HomeScreen() {
const [ name, setName ] = useState(null);
const usersContext = useContext(UsersContext)
const { users, addNewUser } = usersContext;
const insertUser = () => {
addNewUser(name)
}
return (
<View style={styles.container}>
<Text>Our list of users</Text>
{users.map((user) => (
<Text key={user.id}>{user.name}</Text>
))}
<TextInput
style= { styles.input }
onChangeText={(name) => setName(name)}
value={name}
placeholder="enter new name..."
/>
<Button title="insert user" onPress={insertUser}/>
</View>
);
}
HomeScreen.navigationOptions = {
header: null,
};
const styles = StyleSheet.create({
input: {
margin: 15,
padding: 10,
height: 40,
borderColor: '#7a42f4',
borderWidth: 1,
},
container: {
flex: 1,
backgroundColor: '#fff',
},
});
Conclusion
I think this set up will allow me to easily re-use the database related code, without much overhead in each component that needs the data.
I am not certain that having the users and the functions to set the uses in a context
is the best approach, but it seems appropriate for my small use case.