Sometimes, to provide a full set of features in a functional and useful app, we don’t really need to be connected to the internet. Why send data to an API server somewhere and pay a bunch of money to store that data if we don’t have to?
Sometimes, the user just wants an app that can store some info and show it later without having to worry about having a stable internet connection. If you agree, but you’re not quite sure how to build such an app, then well done — you’re reading the right blog post today. 🙂
In this post, I will walk you through building a React Native app that lets the user track their weight and, over time, visualize the stored data on demand. All that with no need to connect to the big bad internet!
Nothing — you’re welcome to the table no matter what you bring.
However, this post will assume that you have some familiarity with TypeScript and React Native, and a basic understanding of databases. If you’re not familiar with one or all of the above, you are still welcome to read along, but some things might seem a bit too cryptic and will require more research on your end.
For the impatient ones, like me, who want to see the code first before committing to reading through a post, here’s the entire codebase on GitHub.
What’s the first and foremost problem for any business? Revenue stream, right?
Wrong! It’s the name. Picking the correct name is always the main thing everyone needs to focus on before anything else when building an app.
So, of course, before I even started writing this post, I came up with the name Weightress, which, if you haven’t noticed, is some very clever wordplay on the fact that the app will track your weight. Amazing, right?
What? You think you can do better than that? Well, I’d like to see you try — tweet me if you can come up with a better name for a weight tracking app!
Alright, enough kidding around. Let’s get to work now.
If you’re new to React Native, before you get started, you might have to set up your machine with some tools. Head over to RN’s official environment setup documentation and go through the installation process. Once you have everything set up, you can have React Native CLI create a boilerplate app for you by simply running:
npx react-native init weightress --template react-native-template-typescript
This will generate a new folder named weightress
and drop a bunch of files in it. Open up the folder with your favorite code editor and navigate inside the folder from your terminal window.
For storing users’ data on their devices, we will be using WatermelonDB. First, we will be installing it as a dependency using Yarn, which is the default package manager for the boilerplate React Native app. Run the following commands:
cd weightress yarn add @nozbe/watermelondb @nozbe/with-observables yarn add --dev @babel/plugin-proposal-decorators
The first command installs the WatermelonDB-related dependencies and the second installs a dev-only package that we’ll need to use the ES6 decorator syntax in our code.
Just installing the package won’t work out of the box; you still have to let Babel know about its existence. Open up the babel.config.js
file and add a new plugin property like so:
module.exports = { presets: ['module:metro-react-native-babel-preset'], plugins: [ ["@babel/plugin-proposal-decorators", { "legacy": true }] ] };
Now, depending on which platform you’re building on, you will have to follow a different set of instructions to finish up the DB setup. For iOS, please follow these steps, and for Android, follow these steps.
OK, you’ve made it through well and alive. It’s a bit tedious to set up packages for native development, so hopefully, the maintainers of the package will soon implement a way to auto-link.
Now, you can either connect your device to your development machine or run the app in an emulator. I’m gonna use my physical Android device, but either way, you just need to run yarn start
in your terminal, and then in another terminal window, run yarn ios
or yarn android
depending on which platform you want to run the app on.
That should open up the app on your device/emulator, and you should see a screen like this:
When building a new app or a feature, I like to start planning from the ground up. The right place for that kind of planning is at the database level. Figuring out what kind of data we will be storing and what each entity will contain gives me a much better understanding of the approach needed to build the UI and UX.
Our app will allow the user to input their weight whenever they want, and we will save it in the database. Then, at a later time, we will retrieve the historical entries and display them to the user. For this simple data, we only need to store a number input from the user and a timestamp of the input.
Also, it would probably be nice to let the user input additional notes when they are recording their weight. Maybe they would want to write down how they are feeling on that day, what they ate, etc. Notes are also helpful for quickly searching for specific things. Now let’s facilitate this data storage using WatermelonDB.
Create a new folder named data/
and a new file inside named schema.ts
and put the following code in it:
import {appSchema, tableSchema} from '@nozbe/watermelondb'; export default appSchema({ version: 1, tables: [ tableSchema({ name: 'weights', columns: [ {name: 'weight', type: 'number'}, {name: 'created_at', type: 'number'}, {name: 'note', type: 'string', isOptional: true}, ], }), ], });
In the code above, we have set up the schema for a table named weights with three columns. Notice that the note
column has an isOptional
property set to true
. We don’t want to force the user to input a note for every entry, which is why we’re making it optional.
Also, the created_at
column has a number type even though it’s a date. That’s because dates are stored as Unix timestamps in WatermelonDB. Oh, and isIndexed
is set to true only for note
and create_at
because we will be letting the user search through their notes or find entries from a specific day. Indexing makes queries run much faster when those columns are involved.
That’s all for setting up the database, but our code still needs to communicate with that data. That’s where models come in. Models are representations of raw data in code. Usually, you’d have one model for every table. So let’s create our model for the weights table.
Create a new file named weight.ts
inside the data/
folder and fill it up with:
import {Model} from '@nozbe/watermelondb'; import {field, readonly, date} from '@nozbe/watermelondb/decorators'; export default class Weight extends Model { static table = 'weights'; @field('note') note; @field('weight') weight; @readonly @date('created_at') createdAt; }
As you can see, it’s pretty much a mirror of the schema we just created for the database table. This is just creating maps between database columns and our model object’s properties. One magical thing here is the @date
decorator that will automatically generate the current timestamp and insert it for us whenever a new entry is inserted into the database.
We’re almost done with the setup phase. Now inside the data/
folder, create another file named database.ts
and put in the following content:
import {Database} from '@nozbe/watermelondb'; import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; import Weight from './weight'; import schema from './schema'; const adapter = new SQLiteAdapter({ schema, }); export const database = new Database({ adapter, modelClasses: [Weight], actionsEnabled: true, });
Here, we’re bringing in the schema and the model we’ve just built and creating a new database instance that knows where to find the schema and the model for our data. With all this in place, we are now ready to create, read, update, and delete data from our local database.
However, in the spirit of keeping concerns separate, we are going to keep all our data-related code in one place instead of accessing/manipulating the database directly from components. Let’s create one last file inside the data/
folder named helpers.ts
and put the following code in there:
import {database} from './database'; export type Weight = { createdAt?: Date; weight: string | number; note: string | undefined; }; const weights = database.collections.get('weights'); export const observeWeights = () => weights.query().observe(); export const saveWeight = async ({weight, note}: Weight) => { await database.action(async () => { await weights.create((entry) => { entry.weight = Number(weight); entry.note = note; }); }); };
Here, we are defining and exporting a type for Weight
with the fields it defined so that data being passed around among and between components can be properly typed. Then, we are defining a container for our weights
table/collection in the database, which we use in the functions below.
For now, we are only defining a getter and a setter. The observeWeights
function sets a subscription on the weights
table, and every time there’s a change in the database, the listener to this subscription will receive the change. A change can be a new entry being created, an existing entry being updated, or an entry being removed.
The last thing being exported is a function that, given a weight and a note, will create a new entry in the database’s weights
table. One thing to notice is that the weight input will come from an input field, which are usually given as strings from React Native. But in the database, we want to store them as numbers, so we are converting the string to a number before saving.
All of the above is backend work — stuff that makes the app work but doesn’t come under the limelight. The UI is what the user sees at the end of it all, so let’s put some nice work in.
Just like before, we will start with a plan and build from the ground up. So, let’s think about our app from a UI perspective for a bit. It can easily be separated into two major sections: a form that lets the user insert weight entry and a list that displays all the previously inserted weights. To make it a little more interesting, instead of a plain old list, let’s use a chart to show historical data.
For a pleasant UX, let’s keep the chart in view at all times and display the input form only on demand. For all that, we can separate out the app in three components: Header
, Creator
, and Chart
. With that in mind, open up the App.tsx
file, remove everything in there, and put in the following code:
import React, {useState} from 'react'; import {SafeAreaView, ScrollView, StatusBar} from 'react-native'; import Chart from './components/chart'; import Header from './components/header'; import Creator from './components/creator'; const App = () => { const [showCreator, setShowCreator] = useState<boolean>(false); return ( <> <StatusBar /> <SafeAreaView> <ScrollView contentInsetAdjustmentBehavior="automatic"> <Header onOpenCreator={() => setShowCreator(true)} /> <Creator isCreatorVisible={showCreator} onHideCreator={() => setShowCreator(false)} /> <Chart /> </ScrollView> </SafeAreaView> </> ); }; export default App;
This is pretty straightforward. We are bringing in the three components we planned for, putting the StatusBar
component on top, and wrapping everything in the SafeAreaView
component to make sure that our content does not overlap with the status bar or get cut off by the dreaded notch. Then, we have yet another wrapper, ScrollView
, that will allow the user to scroll down if the content inside overflows the vertical height of the screen.
We see a local Boolean state named showCreator
, which will determine whether the Creator
is shown to the user. When rendering the Header
component, we pass down a function prop, onOpenCreator
, that simply sets showCreator
to true when called.
In the Creator
component, we are passing down the showCreator
state and a function to hide the creator by setting showCreator
to false.
Then, finally, we have the Chart
component, which doesn’t require any prop. At some point, through some user interaction, the Header
component will trigger onOpenCreator
, and this will bring Creator
into view, which will let the user input their weight.
Now let’s drill down and take a closer look inside each of these components. Create a new folder named components
and create three new files inside: chart.tsx
, creator.tsx
, and header.tsx
.
Header
componentOpen up the components/header.tsx
file and put in the following code:
import React, {FC} from 'react'; import {View, Text, TouchableHighlight} from 'react-native'; import {headerStyles} from './styles'; const Header: FC<{onOpenCreator: () => void}> = ({onOpenCreator}) => { return ( <> <View style={headerStyles.container}> <Text style={headerStyles.headerTitle}>Weightress</Text> <TouchableHighlight style={headerStyles.addButton} onPress={() => onOpenCreator()}> <Text>+ Add</Text> </TouchableHighlight> </View> </> ); }; export default Header;
It’s a small component that renders a header text with just the name of the app, Weightress, and a button that triggers the onOpenCreator
function prop when pressed. The button is rendered using the TouchableHighlight
component, but you could play around with the Button
or Pressable
component.
Notice that we are using headerStyles
to apply styles on all three components, which are imported from a file that we haven’t created yet. So let’s create the file components/styles.ts
and put in the following code:
import {StyleSheet} from 'react-native'; export const primaryColor = '#FB8C00'; export const headerStyles = StyleSheet.create({ container: { alignItems: 'center', paddingVertical: 20, flexDirection: 'row', paddingHorizontal: 15, justifyContent: 'space-between', }, addButton: { borderColor: primaryColor, paddingHorizontal: 20, paddingVertical: 8, borderRadius: 3, borderWidth: 1, }, headerTitle: { fontSize: 25, fontWeight: 'bold', borderLeftWidth: 3, paddingLeft: 10, borderLeftColor: primaryColor, }, });
As you can see, headerStyles
is a React Native stylesheet that has three properties. Each property contains style for one component. The container puts some space around the entire header and makes sure the header text and the add button are placed on opposite sides of the screen using justifyContent: 'space-between'
.
addButton
puts some space around the button on the right-hand side and adds a rounded (but not too rounded) border around the text to make it look more button-esque. Notice that the border color is set using the primaryColor
variable. Isolating the main color accent will help us reuse it in other component styles and makes it easy to change the branding of the app in one place.
Last but not least, we have some styles for the title text with a border below it to make it look more like a logo. We can’t see it in our app just yet, so here’s a screenshot of how it will look with the code above:
Creator
componentOpen up the components/creator.tsx
file and put in the following code:
import React, {FC, useState} from 'react'; import { Button, Modal, Text, TextInput, TouchableHighlight, View, } from 'react-native'; import {saveWeight} from '../data/helpers'; import {creatorStyles, primaryColor} from './styles'; const Creator: FC<{ isCreatorVisible: boolean; onHideCreator: () => void; }> = ({onHideCreator, isCreatorVisible}) => { const [isSaving, setIsSaving] = useState<boolean>(false); const [weight, setWeight] = useState<string>(''); const [note, setNote] = useState<string>(''); const handleSavePress = async () => { setIsSaving(true); await saveWeight({weight, note}); // hide modal onHideCreator(); // Clear out the inputs setWeight(''); setNote(''); // Make button active again setIsSaving(false); }; return ( <Modal animationType="slide" transparent={true} visible={isCreatorVisible}> <View style={creatorStyles.centeredView}> <View style={creatorStyles.modalView}> <View style={creatorStyles.topActions}> <Text>Add your weight</Text> <TouchableHighlight onPress={() => { onHideCreator(); }}> <Text style={creatorStyles.topCloseButton}>×</Text> </TouchableHighlight> </View> <TextInput style={creatorStyles.input} placeholder="Your weight" keyboardType="decimal-pad" onChangeText={(text) => setWeight(text)} value={weight} /> <TextInput placeholder="Additional note (optional)" style={creatorStyles.input} onChangeText={(text) => setNote(text)} value={note} /> <Button title="Save" disabled={isSaving} color={primaryColor} onPress={handleSavePress} /> </View> </View> </Modal> ); }; export default Creator;
This one is a bit more meaty than the Header
component, isn’t it? Let’s unpack this starting with the local states. We have three of those: isSaving
, weight
, and note
.
The helper function that saves weight in the database is an async function, so there might be some delay between requesting the data storage and finishing it. While that is happening, we want to keep the user aware of that activity behind the scenes, which is where isSaving
will be used. The other two will contain input from the user.
Remember we said we’d only show the Creator
on demand? We achieve that through React Native’s Modal
component. Passing true
to the visible
prop of that component will bring it into view, and false
will take it out. We make the transition look and feel good by passing an animationType="slide"
prop.
The Modal
component covers the entire screen, but for the small form with two inputs, we don’t need that much real estate. So we wrap the content in two View
components, and the wrapper puts everything inside of it at the bottom of the screen using some flexbox magic.
Let’s look at the styles that can achieve that. Go back to the styles.ts
file and create a new stylesheet:
import {Dimensions, StyleSheet} from 'react-native'; // previous code..... const windowDim = Dimensions.get('window'); export const windowHeight = windowDim.height; export const creatorStyles = StyleSheet.create({ centeredView: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(255, 255, 255, 0.6)', }, modalView: { backgroundColor: '#FFFFFF', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 10, height: windowHeight / 2, shadowColor: '#cacaca', shadowOffset: { width: 0, height: 1, }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 1, }, }
centeredView
is the parent wrapper that encapsulates everything inside and has a semi-transparent background. justifyContent: 'flex-end'
ensures that whatever element is inside it is put at the bottom of the element. For the View
inside, we use the modalView
style, which has a white background and takes half the height of the screen.
To determine exactly what half of the screen is, we need to use the Dimensions
helpers from React Native, which give us access to the window’s height
and width
and some other size properties of the device. We are also adding some shadows and borders on the element to make things look nice and smooth.
Alright, let’s look back into the component. Inside the modal, first we see this:
<View style={creatorStyles.topActions}> <Text>Add your weight</Text> <TouchableHighlight onPress={() => { onHideCreator(); }}> <Text style={creatorStyles.topCloseButton}>×</Text> </TouchableHighlight> </View>
This adds a header text inside the modal and a button on the right that fires the onHideCreator
prop function when pressed. Remember that we pass this down from App.tsx
, and it lets the user close the modal. Let’s create the styles for this back in the styles.ts
file:
export const creatorStyles = StyleSheet.create({ // previous code... topActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, topCloseButton: { padding: 10, color: '#494949', fontWeight: 'bold', fontSize: 25, },
It has a similar setup to the Header
component, where we split the wrapper in two horizontal sections using flexbox. The close button on the right has some space around it to make it easily clickable, and the text remains on the left.
Now, the main content of the modal:
<TextInput style={creatorStyles.input} placeholder="Your weight" keyboardType="decimal-pad" onChangeText={(text) => setWeight(text)} value={weight} /> <TextInput placeholder="Additional note (optional)" style={creatorStyles.input} onChangeText={(text) => setNote(text)} value={note} /> <Button title="Save" disabled={isSaving} color={primaryColor} onPress={handleSavePress} />
We have two TextInput
components and a Button
here. The first TextInput
takes a number input only because we set the keyboardType="decimal-pad"
prop on it, and it is linked to the weight
state. The second one is for any additional note; hence, it’s connected to the note
state. Finally, we have the Button
that fires off the handleSavePress
function when pressed.
Notice that to disable the button while the input is being saved, we are setting disabled={isSaving}
, and to make it match our branding, we are setting color={primaryColor}
. We haven’t defined the styles for these fields yet, so let’s get back to the styles.ts
file and add this:
export const creatorStyles = StyleSheet.create({ // previous code... input: { height: 50, borderWidth: 1, borderRadius: 3, marginBottom: 10, paddingVertical: 10, paddingHorizontal: 15, borderColor: '#c9c9c9', }, });
We are just adding some spacing around the fields and adding a border to make it look good. Now let’s find out what handleSavePress
does:
const handleSavePress = async () => { setIsSaving(true); await saveWeight({weight, note}); // hide modal onHideCreator(); // Clear out the inputs setWeight(''); setNote(''); // Make button active again setIsSaving(false); };
When fired, it will first toggle the state isSaving
to true, which, in turn, disables the Save button. Then it calls the saveWeight
function with the weight and note inputs from the state. It will asynchronously save the data in WatermelonDB.
Once complete, we first fire off the onHideCreator
function prop, which, if you recall, hides the modal itself. Then we are just clearing out the inputs so that when the modal is opened back up, users see empty input fields instead of their previously inserted weight and note. Finally, we toggle isSaving
back to false to re-enable the Save button for the next time the user opens the modal.
Again, we can’t see it in our app just yet, so here’s a screenshot to wet your beak…
Chart
componentThe React Native ecosystem is missing some of the great charting and data viz libraries available on the web or for native platforms. However, for our needs, react-native-chart-kit offers everything out of the box. The library depends on the react-native-svg library, so let’s install both by running yarn add react-native-svg react-native-chart-kit
.
Now open up the components/chart.tsx
file and put in the following code:
import React, {FC} from 'react'; import withObservables from '@nozbe/with-observables'; import {LineChart} from 'react-native-chart-kit'; import {observeWeights, Weight} from '../data/helpers'; import {chartConfig, chartStyles, windowWidth} from './styles'; const Chart: FC<{weights: Weight[]}> = ({weights}) => { if (weights.length < 1) { return null; } const labels: string[] = []; const data: number[] = []; weights.forEach((w) => { labels.push(`${w?.createdAt.getDate()}/${w.createdAt.getMonth() + 1}`); data.push(w.weight); }); return ( <LineChart bezier height={250} width={windowWidth - 30} chartConfig={chartConfig} style={chartStyles.chart} data={{labels, datasets: [{data}]}} /> ); }; const enhanceWithWeights = withObservables([], () => ({ weights: observeWeights(), })); export default enhanceWithWeights(Chart);
First, we set up the Chart
component to be a functional component that receives an array of weights. If the array is empty, we just won’t render anything. Otherwise, we loop through the array of weights and build a labels
array containing the dates for each entry in the dd/mm
format.
We are also storing all the weight values in a data
array in the same sequence as the labels
array. We are transforming our raw data from the database to this structure that the LineChart
component can understand and visualize. The component simply renders the LineChart
component imported from the react-native-chart-kit library.
The data
prop is where we pass the labels
and data
arrays we’ve just built. The bezier
prop makes the line chart extrapolated to have a curved appearance. We are passing a height
and a width
, where the height is hardcoded but the width is derived from a windowWidth
variable that is imported from the styles.ts
file, along with chartConfig
and chartStyles
.
Let’s see what they are, back in the styles.ts
file:
export const windowWidth = windowDim.width; export const chartStyles = StyleSheet.create({ chart: { marginLeft: 15, borderRadius: 10, }, }); export const chartConfig = { backgroundColor: primaryColor, backgroundGradientFrom: primaryColor, backgroundGradientTo: '#FFA726', decimalPlaces: 2, color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`, labelColor: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`, style: { borderRadius: 16, }, propsForDots: { r: '6', strokeWidth: '2', stroke: '#ffa726', }, };
From the Dimension
module, we are exporting the width of the device’s window and the chart’s width will be 30px less than the full width. Then, in chartStyles
, we are giving it a 15px
margin to the left so that the chart is horizontally centered on the screen with a nice 15px
to either side of it. We are also adding some radius around its border.
Most of the styling is done via the chartConfig
prop instead of React Native style. We are configuring the chart to use a gradient in the background, starting from the primaryColor
accent. The rest of the properties are for various shapes and colors of the content shown inside the chart. You can find out more ways to configure your chart from the library’s official documentation.
Back inside the component, notice that we are not directly exporting the Chart
component, like we did with the two other components. Instead, we are wrapping it in a higher-order component (HOC) and exporting that wrapper version:
const enhanceWithWeights = withObservables([], () => ({ weights: observeWeights(), })); export default enhanceWithWeights(Chart);
This is where the power of WatermelonDB resides. The withObservables
HOC gives us a way to connect a React component directly to the data in our database in real time.
This is where we apply the observeWeights()
helper function, and wrapping our Chart
component with the HOC means that the component will automatically receive a prop named weight
. This prop will contain all the weight entries from the database, and every time new data is added or existing data is updated or removed, the component will re-render with the latest data.
With all this in place, we can finally open up our app on a device or emulator and check it out. Here’s a video of me playing around with it:
Weightress app walkthrough
Screen recording of the weightress app
Notice that if you close the app and reopen it, the chart will display all your previously saved weights thanks to WatermelonDB.
First of all, give yourself a pat on the back for making it this far. But the fun train doesn’t have to stop here. There’s a lot more this app can offer.
Here’s a list of features that you can build on top of this, which will help you learn more about React Native, data structure, storage, and architecture, and in the process, transform this project into a more polished, production-ready app:
Over time, as users input more and more data, the chart view will become too crowded to display everything. Allow the user to select a time period and filter the data from the database to only fetch and display weights from that period
One very common problem with offline apps is data synchronization. Users can lose their phones, break them, reset them, etc. There are many other scenarios where the device-stored data will be lost forever. To avoid such a mess, offline apps need to provide a way to export the data into portable formats like CSV or PDF files.
There should also be an import functionality that lets the users import data from previously exported files. This is especially useful when a user gets a new device and wants to keep using your app on the new device.
It would be super handy if the app could send a reminder push notification at the end of the day if the user does not log their weight that day. Of course, pushing unwanted notifications can be very annoying, so build a settings option so that users can toggle on or off.
With the above three ideas implemented, and any of your own you choose to add, I’m sure you can transform this into the next big app in the stores. Don’t forget to tweet @foysalit if you do publish it on a store or build one or more of these features.
LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "Build a fully offline app using React Native and WatermelonDB"
Any plans to update this guide using the latest WatermelonDB (v0.26)? I believe some of the functions used here are now deprecated. I am trying to get a simple example up and running, but it has not been straightforward 🙁