DEV Community

Cover image for Building an offline-first app with React and CouchDB
Guillaume St-Pierre for Savoir

Posted on • Originally published at blog.savoir.dev

Building an offline-first app with React and CouchDB

About three years ago, I posted an article on the (now defunct) Manifold blog on creating an offline-first app using React and CouchDB. Besides the fact the post is not available anymore, it was also very outdated given how it was built on a very old version of React. Yet, I think the subject matter of the article is still very much a concern today.

A lot of applications require their users to have a constant network connection to avoid losing their work. There are various strategies, some better than others, to make sure users can keep working, even when offline, by syncing their work once they come back online. The technology has improved a lot in three years and I still think CouchDB is a tool worth considering when building an offline-first application.

Join me again as well explore CouchDB and its features as we build a to-read list, which definitely isn't a to-do list in disguise.

What is CouchDB?

CouchDB is a NoSQL database built to sync. The CouchDB engine can support multiple replicas (Think of a database server) for the same database and can sync them in real-time with a process not dissimilar to git. That allows us to distribute our applications all over the world without the database being the limiting factor. These replicas are also not limited to servers. CouchDB compatible databases like PouchDB allow you to have synced databases on the browser or on mobile devices. That enables truly offline-first applications, users work on their own local database that happens to sync with a server when possible and required. The sync depends on the exact replication protocol chosen, and it can be manually triggered. With PouchDB, that happens when any changes trigger a sync. Of course, a server has to be up for the sync to happen! The replication will pause if the replica is offline, which enables the eventual consistency we'll talk about below.

When you create a document in CouchDB, it creates revision for easy merging and conflict detection with its copies. When the database syncs, CouchDB compares the revisions and changes history, tries to merge the documents, and triggers a merge conflict if it can’t.

{  
   "_id":"SpaghettiWithMeatballs",
   "_rev":"1–917fa2381192822767f010b95b45325b",
   "_revisions":{  
      "ids":[  
         "917fa2381192822767f010b95b45325b"
      ],
      "start":1
   },
   "description":"An Italian-American delicious dish",
   "ingredients":[  
      "spaghetti",
      "tomato sauce",
      "meatballs"
   ],
   "name":"Spaghetti with meatballs"
}
Enter fullscreen mode Exit fullscreen mode

All this is handled through a built-in REST API and a web interface. The web interface can be used to manage all your databases and their documents, as well as user accounts, authentication, and even document attachments. If a merge conflict occurs when a database syncs, this interface gives you the ability to handle those merge conflicts manually. It also has a JavaScript engine for powering views and data validation.

Fauxton

Back in 2019, CouchDB was used to power CouchApps. In short, you could build your entire backend using CouchDB and its JavaScript engine. I was a big fan of CouchApps, but the limitation of CouchDB -- and also of database-only backends -- made CouchApps far less powerful than a more traditional database+application server. As we walk the road to v4 (at the time of writing this article), CouchDB has become closer to an alternative to Firebase or Hasura than an alternative to your backend.

So, should I switch everything to CouchDB then?

As with everything in software engineering, it depends.

CouchDB works wonders for applications where data consistency doesn't matter as much as eventual consistency. CouchDB cannot promise all your instances will be consistently synced. What it can promise is that data will eventually be consistent, and that at least one instance will always be available. It’s used or was used by huge companies like IBM, United Airlines, NPM, the BBC, and the LHC scientists at CERN (Yes, that CERN). All places that care about availability and resilience.

CouchDB can also work against you in many other cases. It does not care about making sure the data is consistent between instances outside of syncing, so different users may see different data. It is also a NoSQL database, with all the pros and cons that come with it. On top of that, third-party hosting is somewhat inconsistent; you have Cloudant and Couchbase, but outside of those, you are on your own.

There are a lot of things to consider before choosing a database system. If you feel like CouchDB is perfect for you, then it’s time to fasten your seat belt because you’re in for an awesome ride.

What about PouchDB?

PouchDB is a JavaScript database usable on both the browser and server, heavily inspired by CouchDB. It's a powerful database already thanks to a great API, but its ability to sync with one or more databases makes it a no-brainer for offline capable apps. By enabling PouchDB to sync with CouchDB, we can focus on writing data directly in PouchDB and it will take care of syncing that data with CouchDB, eventually. Our users will keep access to their data, whether the database is online or not.

Building an offline-first app

Now that we know what CouchDB is, let's build an offline-first app with CouchDB, PouchDB, and React. When searching CouchDB + React for the initial article, I found a lot of to-do apps. I thought I was very funny by making the joke that I was creating a to-read app, all while claiming that a list of books to read is totally different to a list of tasks to do. For consistency, let's keep the joke alive. Also, to-read apps are totally different from to-do apps.

All the code for this application is available on GitHub here: https://github.com/SavoirBot/definitely-not-a-todo-list. Feel free to follow along with the code.

The first thing we need is a JavaScript project for our app. We'll use Snowpack as our bundler. Open a terminal located in a directory for the project and type npx create-snowpack-app react-couchdb --template @snowpack/app-template-minimal. Snowpack will create a skeleton for our React application and install all dependencies. Once it's done doing its job, type cd react-couchdb to get into the newly created project directory. create-snowpack-app is very similar to create-react-app in how it sets-up your project, but it's a lot less intrusive (You don't even need to use eject at any point).

To finish setting up the project, install all the dependencies with the following command:

npm install react react-dom pouchdb-browser
Enter fullscreen mode Exit fullscreen mode

With our project in hand, we now need a CouchDB database. To keep things simple, let's start it in a docker container using docker-compose, which will allow us to start and stop it very easily. Create a docker-compose.yaml file and copy this content into it:

# docker-compose.yaml
version: '3'
services:
  couchserver:
    image: couchdb
    ports:
      - "5984:5984"
    environment:
      - COUCHDB_USER=admin
      - COUCHDB_PASSWORD=secret
    volumes:
      - ./dbdata:/opt/couchdb/data
Enter fullscreen mode Exit fullscreen mode

This file defines a CouchDB server with a few variables to set the admin username and password. We also define a volume that will sync the CouchDB data from inside of the container to a local folder called dbdata. This will help keep our data when we close the container.

Type docker compose up -d in a terminal opened in the same folder where you started this project. Once pulled, the container will start, and make your CouchDB database available under http://localhost:5984. Accessing this URL in your browser or with curl should return a JSON welcome message. To make our local application work, we have to configure CORS on our database. Access the CouchDB dashboard under http://localhost:5984/_utils in your browser. Use the configured admin username and password, then click on the Settings tab, followed by the CORS tab, then click on Enable CORS and select All domains ( * ).

CORS configured

Configuring PouchDB for our app

For this project, we'll be using a few hooks to configure PouchDB and fetch our to-read items. Let's start by configuring PouchDB itself. Create a directory called hooks and then create a file called usePouchDB.js in this directory, with this code.

// hooks/usePouchDB.js
import { useMemo } from 'react';
import PouchDB from 'pouchdb-browser';

const remoteUrl = 'http://localhost:5984/reading_lists';

export const usePouchDB = () => {
    // Create the local and remote databases for syncing
    const [localDb, remoteDb] = useMemo(
        () => [new PouchDB('reading_lists'), new PouchDB(remoteUrl)],
        []
    );

    return {
        db: localDb,
    };
};
Enter fullscreen mode Exit fullscreen mode

This hook uses the useMemo hook from React to create two new instances of PouchDB. The first instance is a local database, installed in the browser, called reading_lists. The second instance is a remote instance, which instead connects to our CouchDB container. Since we only need the local instance in our application, we return an object with that local database only.

Let's now configure the synchronization for those two databases. Go back to usePouchDB.js and update the code with these changes.

// hooks/usePouchDB.js
import { useMemo, useEffect } from 'react';
import PouchDB from 'pouchdb-browser';

const remoteUrl = 'http://localhost:5984/reading_lists';

export const usePouchDB = () => {
    // Previous code omitted for brevity
    const [localDb, remoteDb] = useMemo(...);

    // Start the sync in a separate effect, cancel on unmount
    useEffect(() => {
        const canceller = localDb
            .sync(remoteDb, {
                live: true,
                retry: true,
            });

        return () => {
            canceller.cancel();
        };
    }, [localDb, remoteDb]);

    return {
        db: localDb,
    };
};
Enter fullscreen mode Exit fullscreen mode

We added a useEffect hook to start the two-way synchronization between the local and remote databases. The sync uses the live and retry option, which causes PouchDB to stay connected with the remote database rather than only sync once, and retry if the sync could not happen. This effect returns a function which will cancel the sync if the component happens to unmount while syncing.

It would be nice to show a small message to our users whenever the CouchDB database is disconnected or unavailable. PouchDB's sync provides events we can listen to like paused and active, which the doc mentions may trigger when the database is unavailable. However, these hooks are only related to the act of syncing the data. If nothing needs to be synced, the sync will trigger the paused event regardless of the state of the remote database and then ignore the state of the remote database. Instead, we need to use the info method on the database on a regular interval to check the status of the remote database.

// hooks/usePouchDB.js
import { useMemo, useEffect, useState } from 'react';
import PouchDB from 'pouchdb-browser';

const remoteUrl = 'http://localhost:5984/reading_lists';

export const usePouchDB = () => {
    const [alive, setAlive] = useState(false);

    // Previous code omitted for brevity
    const [localDb, remoteDb] = useMemo(...);
    useEffect(...);

    // Create an interval after checking the status of the database for the
    // first time
    useEffect(() => {
        const cancelInterval = setInterval(() => {
            remoteDb
                .info()
                .then(() => {
                    setAlive(true);
                })
                .catch(() => {
                    setAlive(false);
                });
            }, 1000)
        });

        return () => {
            clearTimeout(cancelInterval);
        };
    }, [remoteDb]);

    return {
        db: localDb,
        ready,
        alive,
    };
};
Enter fullscreen mode Exit fullscreen mode

We added the state hook for the variable alive, which will track if the remote database is available. Next, we added another useEffect hook to set up an interval that will call the info method every second to check if the database is still alive. Like the previous useEffect, we need to make sure to cancel the interval when the component unmounts to avoid memory leaks.

Fetching all the documents

With our PouchDB hook, we are ready to create our next hook for fetching all the to-read documents from the local database. Let's create another file in the hooks directory called useReadingList.js for the documents fetching logic.

// hooks/useReadingList.js
import { useEffect, useState } from 'react';

export const useReadingList = (db, isReady) => {
    const [loading, setLoading] = useState(true);
    const [documents, setDocuments] = useState([]);

    // Function to fetch the data from pouchDB with loading state
    const fetchData = () => {
        setLoading(true);

        db.allDocs({
            include_docs: true,
        }).then(result => {
            setLoading(false);
            setDocuments(result.rows.map(row => row.doc));
        });
    };

    // Fetch the data on the first mount, then listen for changes (Also listens to sync changes)
    useEffect(() => {
        fetchData();

        const canceler = db
            .changes({
                since: 'now',
                live: true,
            })
            .on('change', () => {
                fetchData();
            });

        return () => {
            canceler.cancel();
        };
    }, [db]);

    return [loading, documents];
};
Enter fullscreen mode Exit fullscreen mode

This hook does a few things. First, we create some state variables for keeping the loading state and our fetched documents. Next, we define a function to fetch the documents from the database using allDocs, then adding the documents to our state variables once loaded. We use the include_docs option for the allDocs function to make sure we fetch the entire document. By default, allDocs will only return the ID and revision. include_docs makes sure we get all the data.

We then create a useEffect hook which starts the data fetching process, then listen to changes from the database. Whenever we change something through the app, or the synchronization changes data in the local database, the change event will be triggered and we'll fetch the data again. The live option makes sure this keeps happening for the entire lifecycle of the application, or until the listener is cancelled when the component unmounts.

Putting it all together

With our hooks ready, we now need to build the React application. First, open the index.html file created by snowpack and replace <h1>Welcome to Snowpack!</h1> with <div id="root"></div>. Next, rename the index.js file created by snowpack to index.jsx and replace the content of that file with this code:

// index.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';

const App = () => null;

createRoot(document.getElementById('root')).render(<App />);
Enter fullscreen mode Exit fullscreen mode

You can now start the snowpack app with npm run start, this should start the application, give you a URL to open in your browser, and show you a blank screen (normal since we return null from our app!). Let's start building our App component.

// index.jsx
// rest of the code remove for brevity
import { usePouchDB } from '../hooks/usePouchDB';
import { useReadingList } from '../hooks/useReadingList';

const App = () => {
    const { db, ready, alive } = usePouchDB();
    const [loading, documents] = useReadingList(db);

    return (
        <div>
            <h1>Definitely not a todo list</h1>
            {!alive && (
                <div>
                    <h2>Warning</h2>
                    The connection with the database has been lost, you can
                    still work on your documents, we will sync everything once
                    the connection is re-established.
                </div>
            )}
            {loading && <div>loading...</div>}
            {documents.length ? (
                <ul>
                    {documents.map(doc => (
                        <li key={doc._id}>
                            {doc.name}
                        </li>
                    ))}
                </ul>
            ) : (
                <div>No books to read added, yet</div>
            )}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

The application loads our PouchDB hook, followed by our hook loading all our to-read items. We'll then return a basic HTML structure that can show a warning message if the database happens to disconnect, a loading message when we're fetching the documents, and finally the to-read items from the database. The _id property is the internal unique ID property in CouchDB/PouchDB, which makes a perfect key for our list items.

Showing all the items is pretty nice, but to be able to show any items, we need a way to add new to-read items to our database. Let's go back to our index.jsx file and add this code in these.

// index.jsx
import React, { useState } from 'react';
// rest of the code remove for brevity

import { usePouchDB } from '../hooks/usePouchDB';
import { useReadingList } from '../hooks/useReadingList';

// Component to add new books with a controlled input
const AddReadingElement = ({ handleAddElement }) => {
    const [currentName, setCurrentName] = useState('');

    const addBook = () => {
        if (currentName) {
            // If the currentName has data, clear it and add a new element.
            handleAddElement(currentName);
            setCurrentName('');
        }
    };

    return (
        <div>
            <h2>Add a new book to read</h2>
            <label htmlFor="new_book">Book name</label>
            <input
                type="text"
                id="new_book"
                value={currentName}
                onChange={event => setCurrentName(event.target.value)}
            />
            <button onClick={addBook}>Add</button>
        </div>
    );
};

const App = () => {
    const { db, ready, alive } = usePouchDB();
    const [loading, documents] = useReadingList(db);

    const handleAddElement = name => {
        // post sends a document to the database and generates the unique ID for us
        db.post({
            name,
            read: false,
        });
    };

    return (
        <div>
            {/* rest of the code remove for brevity */}
            <AddReadingElement handleAddElement={handleAddElement} />
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

We added a new component to this file for adding new books to read. A separate component helps make the structure a bit clearer, feel free to extract it in another file. This component uses a state hook to control an input, and then triggers the post method on the local database when the Add button is clicked.

Go back to your browser and try adding a few books to read, they should show up in the list when the button is clicked.

Finally, it would be great to be able to set books as read or delete some books we don't want in our list anymore. Open the index.jsx file again and add this code in there.

// index.jsx
// rest of the code remove for brevity
const App = () => {
    const { db, ready, alive } = usePouchDB();
    const [loading, documents] = useReadingList(db);

    // rest of the code remove for brevity
    const handleAddElement = name => ...;

    // The remove method removes a document by _id and rev. The best way to send
    // both is to send the document to the remove method
    const handleRemoveElement = element => {
        db.remove(element);
    };

    // The remove method updates a document, replacing all fields from that document.
    // like _id and rev, it needs both to find the document.
    const handleToggleRead = element => {
        db.put({
            ...element,
            read: !element.read,
        });
    };

    return (
        <div>
            {/* rest of the code remove for brevity */}
            {documents.length ? (
                <ul>
                    {documents.map(doc => (
                        <li key={doc._id}>
                            <input
                                type="checkbox"
                                checked={doc.read}
                                onChange={() => handleToggleRead(doc)}
                                id={doc._id}
                            />
                            <label htmlFor={doc._id}>{doc.name}</label>
                            <button
                                onClick={() => handleRemoveElement(doc)}
                            >
                                Delete
                            </button>
                        </li>
                    ))}
                </ul>
            ) : (
                <div>No books to read added, yet</div>
            )}
            {/* rest of the code remove for brevity */}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

We added two functions in our App. The update method uses the put method to update a document. The post method on the local database creates a document without a unique ID and generates it once the element is inserted. put can both update and insert, but it requires an ID and revision to select the document to put. In our case, we use it using the existing document, toggling the read property. The second function uses the remove method with the document, which makes sure PouchDB can find the document and delete it.

Finally, we replaced the list of documents to add a checkbox and a button. When the checkbox is toggled, the update method will fire and toggle the read property. The button will fire the remove method to delete the element when clicked.

Go back to your browser and try toggling the checkboxes or deleting elements. It should work without any issues.

Testing the offline-first capabilities

Now, it's time to test the app while the database is offline. Open a new terminal where your project is located (so as not to kill the npm run start command) and type docker compose stop couchserver. You should immediately see the warning message appear in the React app. Yet, you should still be able to interact with the app and add/change/delete documents. Type docker compose start couchserver to restart the database and reload the page once the warning message disappears. Every change you made should still be in the app, and you should be able to see the change in the CouchDB dashboard.

Conclusion

We now have a functional app with an offline-first focus. Regardless of the state of the database, our users can keep adding books to read and set their read state. The message is an added bonus which helps our users know not to clear their cache until we have properly synced the app.

Of course, acting on the database directly from the client may not be the best solution for most apps. Especially if we sync that data without any validation from the database. Please let me know in the comments below if you'd like a second post in this series implementing a backend for validating and syncing data in an offline-first application.


I'd love to hear your thoughts - please comment or share the post

We are building up Savoir, so keep an eye out for features and updates on our website at savoir.dev. If you'd like to subscribe for updates or beta testing, send me a message at info@savoir.dev!

Savoir is the french word for Knowledge, pronounced sɑvwɑɹ.

Top comments (1)

Collapse
 
wchorski profile image
William Chorski

I believe there is a typo in the "setInterval()" function in usePouchDB.js

useEffect(() => {
const cancelInterval = setInterval(() => {
remoteDb
.info()
.then(() => {
setAlive(true)
})
.catch(() => {
setAlive(false)
})
~~ }, 1000)~~
}, 1000);

return () => clearTimeout(cancelInterval) 
Enter fullscreen mode Exit fullscreen mode

}, [remoteDb]);