Foysal Ahamed Write code in between naps.

Build a fully offline app using React Native and WatermelonDB

16 min read 4645

Build a Fully Offline App Using React Native and WatermelonDB

Pep talk

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!

What you need to bring to the table

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.

Getting started

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!

We made a custom demo for .
No really. Click here to check it out.

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:

React Native Starter Template

Data structure

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.

User interface

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 component

Open 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:

Preview of Our Header Component

Creator component

Open 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…

Preview of Our Creator Component

Chart component

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

Take it for a spin

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:

Notice that if you close the app and reopen it, the chart will display all your previously saved weights thanks to WatermelonDB.

Go big or go home

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:

Time period selector

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

Import/export data

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.

Reminder notifications

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.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Foysal Ahamed Write code in between naps.

Leave a Reply