Chapter 12. Persisting user data and using native Node.js modules

This chapter covers

  • Using Node.js modules built with C++ in your main and renderer processes
  • Getting the correct versions of your dependencies for Electron’s version of Node
  • Using SQLite and IndexedDB databases to persist data
  • Storing user-specific application data in the operating system’s designated location

In chapter 11, we built a small application to track items we needed to pack between trips. By the end of the chapter, we got the UI working, but the application still had a fatal flaw: it lost all of its data whenever the page refreshed. That’s a bit of a deal killer for an application that is allegedly supposed to help you remember things. Fire Sale was working with files stored on disk, so it wasn’t an issue, but Clipmaster and Clipmaster 9000 had this problem as well. All clippings were lost whenever a user quit the application, or we refreshed the page in development.

In this chapter, we solve this problem once and for all. Data is persisted between page loads and remains available even if the user quits the application and restarts their computer. For good measure, we solve this problem two ways: we create a local SQLite database and a browser-based IndexedDB storage. Along the way, we also cover some interesting implementation details: Where do we store data on a per-user basis? How do we build compiled modules for Electron’s version of Node if it differs from the version installed on our computers? How does working with an SQL database like SQLite differ from a NoSQL database like IndexedDB?

Removing items wasn’t particularly important in chapter 11 when we couldn’t hold on to data, but it certainly is now. By the end of this chapter, we’ll have an application that looks suspiciously similar to what we had at the beginning but with a few major differences: The data is persisted to disk and users can remove items from the list and remove all items that haven’t been packed.

12.1. Storing data in an SQLite database

The first approach that we take is storing our data in an SQLite database. This approach is commonly used by traditional native applications, particularly on macOS and iOS. SQLite is a good choice because the database is stored in a file and doesn’t require the user to have MySQL or PostgreSQL installed on their system.

If you’re a recovering database administrator, I’ll give you fair warning: I’m not going to optimize every query, and some techniques might be a little wasteful. I’m optimizing for clarity of the code over performance. We know that—in this case—we’re working with a very small data set.

If you’re coming from a frontend, web development background, you may not have thought to use an SQLite database in your application. You’ve typically sent HTTP requests to a server or used a browser-based solution like IndexedDB, WebSQL, or LocalStorage. The reason that SQLite databases aren’t used frequently in traditional web applications is because they can’t be. We don’t have access to the filesystem from the browser. Also, we can’t use what I’m going to refer to as native modules for the duration of this chapter.

What is a native module? Many libraries—like Lodash or Moment.js—are written purely in JavaScript. Both the browser and Node can execute JavaScript, so these modules can be used in either context. Some libraries—like jQuery—are tightly coupled with the DOM and therefore work only in the browser context. Native modules typically wrap a C or C++ library in JavaScript. The C or C++ component of the library must be compiled for the operating system in which the library will be used. SQLite—and most other database drivers—have libraries written in C or C++. The sqlite3 modules on npm wrap this library in JavaScript bindings so that we can use it from within our Node applications. Not only does the browser not have access to the filesystem, it also can’t run platform-specific C and C++ code. Some projects like Emscripten compile C code to run on the JavaScript virtual machine, but that is far beyond the scope of this book.

As we’ve discussed throughout the book, Electron applications combine a Chromium browser runtime with a Node runtime, so we can use native modules in our applications. We start by setting up an SQLite database, creating a table for our items, and connecting the UI to read from and write to the database—directly from the renderer process.

12.1.1. Using the right versions with electron-rebuild

When we install a native module in Node, it is compiled against the current version of the V8 engine used by Node. Upgrading versions of Node typically results in having to recompile all the native modules used by the application. Electron ships with its own Node runtime, which may or may not be the same version as the Node running on your computer when you run npm install or yarn install. This mismatch can cause problems when you attempt to use native modules with an Electron application.

Lucky for us, the community has been kind enough to provide us with a solution called electron-rebuild, which rebuilds native modules against the version of Node used by Electron as opposed to the version installed on the filesystem. You can install electron-rebuild via npm using npm install electron-rebuild --save-dev. It can then be triggered by using $(npm bin)/electron-rebuild on macOS or . ode _modules.binelectron-rebuild.cmd on Windows.

I prefer not to be burdened by having to remember to call it every time I install a dependency. Instead, I recommend using a postinstall hook in your package.json, which will run after every install.

Listing 12.1. Adding a postinstall hook: /package.json
{
  "name": "jetsetter",
  "version": "1.0.0",
  "description": "An application for keeping track of the things you need
 to pack.",
  "main": "app/main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo "Error: no test specified" && exit 1",
    "postinstall": "electron-rebuild"                         1
  },
  // Additional configuration omitted for brevity.
}

  • 1 The postinstall script is called after each run of npm install.

By default, npm checks for a pre– or postscript before running any script. When you run npm test, it first tries to run pretest, then test, followed by posttest. I’ve found this trick incredibly helpful across a wide variety of projects over the years.

12.1.2. Setting up SQLite and Knex.js

In this chapter, we’re using a helpful library called Knex.js to make working with SQL in Node a bit easier. It acts as our interface between our application code and the underlying SQLite queries. This is set up for you in the chapter-12-beginning branch on the Jetsetter repository. If you want to build off your implementation from chapter 11, you can install these dependencies using npm install sqlite3 knex.

Before we can integrate SQLite into our application, we have to do some initial setup. As I mentioned earlier, SQLite stores data in a file so we need to figure out where we’re going to store this file. We start with a simple but flawed solution for now. Later in the chapter, I’ll revisit the best place to store user data, but for now let’s focus on getting our application working.

Listing 12.2. Setting up an SQLite database: ./app/database.js
import 'sqlite3';                    1
import knex from 'knex';

const database = knex({
  client: 'sqlite3',                 2
  connection: {
    filename: './db.sqlite'          3
  },
  useNullAsDefault: true             4
});

export default database;             5

  • 1 Pulls in the SQLite library
  • 2 Tells Knex.js that we’re intending to use it with an SQLite database
  • 3 Specifies the location where the SQLite database should be created
  • 4 Configures Knex.js to use NULL whenever a value for a particular column isn’t provided.
  • 5 Exports the configured database.

We need to include the sqlite3 library so it’s loaded into the application when Knex goes looking for it, as well as let Knex know that we’ll be using SQLite as our database for this application.

Calling this property connection is a bit of a misnomer here. If we were connecting to a MySQL server, this name would be fine, but as I mentioned, SQLite uses a file stored on disk, so we put the name of the location where we want to store the file here. I’ve called it db.sqlite for now. We revisit this later.

What’s with that useNullAsDefault option? Knex.js isn’t a library specifically for SQLite. Rather, it works with PostgreSQL, MySQL, MSSQL, and Oracle databases. Many of these support default values for columns in the database. SQLite doesn’t, and Knex displays a warning if we don’t turn on this option, which causes Knex.js to opt for NULL instead of attempting to use a default value.

This isn’t enough to get us all the way there. We created the database, but we still haven’t configured it with a table to store our items. When the application starts, we check if there’s a table for storing items. If there isn’t, then we create the table.

Listing 12.3. Creating a table to store items: ./app/database.js
import 'sqlite3';

const database = require('knex')(//...);

database.schema.hasTable('items').then(exists => {         1
  if (!exists) {                                           2
    return database.schema.createTable('items', t => {     3
      t.increments('id').primary();                        4
      t.string('value', 100);                              5
      t.boolean('packed');                                 6
    });
  }
});

export default database;

  • 1 Checks if the database already has an items table
  • 2 Moves forward only if the items table doesn’t exist
  • 3 Creates the items table
  • 4 Creates an id column to serve as the primary key and auto-increments it
  • 5 Sets the value column to a string with a width of 100 characters
  • 6 Sets the packed column to store a Boolean type

Knex.js uses a promise-based API. The check for the table returns a promise that is fulfilled with a Boolean based on whether it does in fact exist. All of our queries in the next section are based on promises. The data in Jetsetter is deliberately simple, and our schema reflects that. SQLite creates a unique ID and auto-increments the ID on each new item added to the database. We also store the item’s name as a string in the value column and a Boolean that represents whether the item has been packed.

12.1.3. Hooking the database into React

In a traditional web application, if we want to put something into an SQLite database, we’d likely have to send AJAX requests to a server, which would interact with the database. This means that we’d probably also implement some kind of authentication as well as authorization to make sure that users couldn’t read or edit another user’s data. In an Electron application, we can talk directly to the database from our client code.

At the top level, we pull in the configured database we just created and pass it to the Application component as a prop—React-speak for “property”—which has access to the database inside of its methods.

Listing 12.4. Passing the database into the application component: ./app/renderer.js
import React from 'react';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import database from './database';                    1

const renderApplication = async () => {
  const { default: Application } = await import('./components/Application');
  render(
    <AppContainer>
      <Application database={database} />             2
    </AppContainer>,
    document.getElementById('application')
  );
};

renderApplication();

if (module.hot) { module.hot.accept(renderApplication); }

  • 1 Requires the database we created in ./app/database.js.
  • 2 Passes it into the Application as a property.

Now comes the fun part. Previously, we stored the state of the application in the Application component. Every time the application is reloaded, that state is replaced. Every time the user quits the application, the state is gone for good. In the next few examples, we replace this behavior with reading from and writing to the database.

This process contains a few pieces. When the Application component starts for the first time, it reads all of the items from the database and loads them into its internal state. It also does this whenever it has reason to believe that the data has changed, which allows us to have one source of truth: the database. Depending on your needs, you may decide you want to approach your data storage strategy differently, but this method works for us in Jetsetter.

When a user creates a new item, we add it to the database. When they check off an item, we update it in the database by flipping the “packed” Boolean to its opposite. When the user selects Mark All as Unpacked, we—unsurprisingly—select all of the items for the database and set their “packed” property to false.

As I mentioned at the beginning of the chapter, we’re adding the ability to remove an item from the database. This capability wasn’t important back when we lost everything on every reload, but it’s necessary now. In addition, we add a button to remove all of the unpacked items from the database.

12.1.4. Fetching all of the items from the database

We continue using this.state to hold a list of the items most recently fetched from the database. But instead of hard-coding a pair of pants into this list, we add a method that fetches all of the items from the database and then updates this list. We also call this method whenever the component starts for the first time.

Listing 12.5. Fetching items from the database: ./app/components/Application.js
class Application extends Component {
  constructor(props) {
    super(props);

    this.state = {
      items: []                                            1
    };

    this.fetchItems = this.fetchItems.bind(this);          2
    this.addItem = this.addItem.bind(this);
    this.markAsPacked = this.markAsPacked.bind(this);
    this.markAllAsUnpacked = this.markAllAsUnpacked.bind(this);
  }

  componentDidMount() {
    this.fetchItems();                                     3
  }

  fetchItems() {                                           4
    this.props
      .database('items')
      .select()
      .then(items => this.setState({ items }))             5
      .catch(console.error);
  }

  addItem(item) { ... }
  markAsPacked(item) { ... }
  markAllAsUnpacked() { ... }

  render() { ... }
}

export default Application;

  • 1 Sets the initial state of the component to an empty array
  • 2 Binds the fetching function so that it has access to the correct context
  • 3 Fetches the items from the database as soon as the component starts
  • 4 Queries the database for a list of items
  • 5 Updates the array of items stored in state

We don’t want to get any errors about trying to map or iterate over an undefined value, so we set the component’s initial state to an empty array. In the previous example, we take the database instance that was passed into the Application component as a property and ask for its items table, which we set up earlier. The .select() method selects all of the rows from that table—which are all of the items in our case—and returns a promise. If everything is successful, we can use .then() to take the results from the .select() method and work with those rows.

We start with the list of items being an empty array. this.fetchItems() queries the database and returns a promise. When this promise resolves, we swap out items being stored in the component’s state with the items we received from the database. React is smart enough to figure out what this means in terms of changing the UI on our behalf. We don’t need to worry about that.

Because this.fetchItems() does its work asynchronously, we need to bind it to the context of the component as we did with this.addItem(), this.markAsPacked(), and this.markAllAsPacked(). Finally, we need to call this.fetchItems() to load the initial state. We do this once the component has started, which immediately updates the state of the component.

12.1.5. Adding items to the database

The functionality we implemented is impressive. We’re connecting directly to the database from the UI. This isn’t something we’ve been able to do in traditional web applications. But it doesn’t seem to feel that impressive just yet, because there isn’t anything in the database to show. It’s only an empty list at the moment. We could put mock data in there, but let’s cut to the chase and implement the ability to add new items to the database.

If you recall from chapter 11, we passed a function from the Application component to the NewItem component. When a user clicks the Submit button, the NewItem component took the contents of its input field and passed it into the function provided by the Application component, which—in turn—pushed it onto the end of the array of items stored in state.

We’re going to leave much of this functionality in place with one notable exception: instead of pushing the item onto the end of the array stored in the Application component’s state, we insert it into the database and then trigger this.fetchItems() to reload all of the items now in the database. We start by rewriting this.addItem() in the Application component to use the database instead of an in-memory array.

Listing 12.6. Implementing the ability to add items to the database: ./app/components/Application.js
class Application extends Component {
  constructor(props) { ... }
  componentDidMount() { ... }
  fetchItems() { ... }

  addItem(item) {
    this.props
      .database('items')
      .insert(item)                      1
      .then(this.fetchItems);            2
  }

  markAsPacked(item) { ... }
  markAllAsUnpacked() { ... }

  render() { ... }
}

export default Application;

  • 1 Inserts the item into the database.
  • 2 When inserting the item into the database has completed, refetches all of the items.

Accessing the items table in the database is the same as it is for fetching all of the items. The main difference is that we use the insert() method to add the new item from the NewItem component to the database. When that has successfully completed, we call this.fetchItems(), which gets the most up-to-date list of items and subsequently updates the state of the UI.

We can add items to the database and see them in the UI, but we have a subtle problem. React is particular about having unique keys for every item. We engaged in a less-than-optimal hack by using the current date as an integer for the unique key. This worked, but I didn’t feel good about it at the time, and I certainly don’t feel good about it now, considering we have a database that tracks and auto-increments unique IDs. Let’s remove our trick for manually creating unique IDs.

Listing 12.7. Letting the database handle incrementing the ID: ./app/components/NewItem.js
class NewItem extends Component {
  constructor(props) { ... }
  handleChange(event) { ... }

  handleSubmit(event) {
    const { onSubmit } = this.props;
    const { value } = this.state;

    event.preventDefault();
    onSubmit({ value, packed: false });       1
    this.setState({ value: '' });
  }

  render() { ... }
}

  • 1 Removes the property that sets an ID on our new item

It might be difficult at first to notice the change, but we just engaged in one of my favorite activities as a software engineer: deleting code. Previously, we passed in three properties: value, packed, and id. By omitting our own ID, SQLite creates one on our behalf, thus eliminating our need to rely on weird tricks involving the time-space continuum.

12.1.6. Updating items in the database

Most applications that work with data implement the four basic CRUD operations: create, read, update, and delete. We’re able to read all of the items from the database, and we just implemented the ability to create new items. We’re halfway there. Our next step is to be able to update existing items.

The UI of our application provides two ways to update the state of an item in the database: Users can check or uncheck the check box input associated with the item. Users can click the Mark All as Unpacked button to manipulate all of the items in the database. We need two, slightly different approaches for each case.

Let’s start with the case where we want to update a single item in the database. To accomplish this, we need to perform two operations: find the particular item we want to update and then update it.

Listing 12.8. Marking items as packed: ./app/components/Application.js
class Application extends Component {
  constructor(props) { // ... }
  componentDidMount() { // ... }
  fetchItems() { // ... }
  addItem(item) { // ... }

  markAsPacked(item) {
    this.props
      .database('items')
      .where('id', '=', item.id)          1
      .update({
        packed: !item.packed              2
      })
      .then(this.fetchItems)
      .catch(console.error);
  }

  markAllAsUnpacked() { // ... }

  render() { // ... }
}

export default Application;

  • 1 Finds the item with the correct ID
  • 2 Updates the packed column of the item to the opposite of its current state

We find all of the items where the value in the id column matches the ID of the item in the UI that was just clicked. Hint: Only one of these exists. We then use the update() method to update the packed column to the opposite of whatever the item is in the UI. If this action is successful, we get all of the items—including our newly updated item—and refresh the UI. If something goes wrong, we log that error to the console for debugging purposes. A more robust application would either implement a fallback here or—at the very least—display some kind of notification alerting the user to the fact that their change couldn’t be completed successfully.

We can now change the status of one item, but what about all of them? The answer lies somewhere between the implementation of this.fetchItems() and this.markAsPacked(). With this.markAsPacked(), we found the item with an ID that matched the one we’re looking for and then updated it. With this.fetchItems(), we used select() to get all of the items. To implement this.markAllAsPacked(), we get all of the items in the entire set.

Listing 12.9. Marking all items as packed: ./app/components/Application.js
class Application extends Component {
  constructor(props) { // ... }
  componentDidMount() { // ... }
  fetchItems() { // ... }
  addItem(item) { // ... }
  markAsPacked(item) { // ... }

  markAllAsUnpacked() {
    this.props
      .database('items')
      .select()                1
      .update({
        packed: false          2
      })
      .then(this.fetchItems)
      .catch(console.error);
  }

  render() { // ... }
}

export default Application;]

  • 1 Selects all of the items from the database
  • 2 Updates all of the items by setting their packed column to false.

As I mentioned before, this implementation roughly combines two of our previous approaches. Selecting all of the items from the database allows you to make changes in bulk using SQL. This case is also true for the .where() method that we used when updating a single item. It just happened to be that when you query based on a unique identifier, you—hopefully—end up with only one record. this.markAllAsPacked() could be changed to use .where() to find all of the items where packed was set to true. I leave this as an exercise to the reader because I’m going to discuss it in the next section.

12.1.7. Deleting items

When our application lost all of its data on every reload, we didn’t have a lot of users clamoring for the ability to remove items. But now we’re in a place where we’ve implemented persistent storage. Maybe you’ve decided that you don’t need to travel with your selfie stick anymore, and you’d like to remove it from the list.

We now implement two features: the ability to remove an individual item, and the ability to remove all items that weren’t packed, which might include our selfie stick, Furby, and ugly sweater. We need UI elements for this as well, but let’s start with the database piece of this.

Listing 12.10. Deleting items: ./app/components/Application.js
class Application extends Component {
  constructor(props) {
    super(props);

    // Omitted for brevity...

    this.deleteItem = this.deleteItem.bind(this);                     1
    this.deleteUnpackedItems = this.deleteUnpackedItems.bind(this);   2

  }

  componentDidMount() { ... }
  fetchItems() { ... }
  addItem(item) { ... }
  markAsPacked(item) { ... }
  markAllAsUnpacked() { ... }

  deleteItem(item) {
    this.props
      .database('items')
      .where('id', item.id)                                           3
      .delete()                                                       4
      .then(this.fetchItems)
      .catch(console.error);
  }

  deleteUnpackedItems() {
    this.props
      .database('items')
      .where('packed', false)                                         5
      .delete()
      .then(this.fetchItems)
      .catch(console.error);
  }

  render() { ... }
}

export default Application;

  • 1 Binds the context for the this.deleteItem() method
  • 2 Binds the context for the this.deleteUnpackedItems() method
  • 3 Finds the item that matches the ID of the item selected from the UI
  • 4 Uses the delete() method to remove the item from the database
  • 5 Finds all of the items where the packed property is set to false

Careful readers can see I used a slightly different syntax here than this.mark-AsPacked(). When you’re trying to match based on equality, you can use this shorthand where you provide the name of the column and the value you’re trying to match. In the previous example, we provided an operator. This method is powerful because we could filter using more sophisticated logic. In this.deleteUnpacked-Items(), we are using the where() method to find all of the items where the packed property is set to false.

This is all well and good, but it’s somewhat difficult to know if they work because we don’t have any UI to trigger these methods. This is suspiciously similar to how we set the check boxes to toggle items between their packed and unpacked states, but that was an entire chapter ago. Let’s review the process.

We pass the this.deleteItem() method to each of the lists. Each list, in turn, passes this method to the items on the list. We add a check box X button to each item. When a user clicks the X button, we pass a reference to the specific item to this.deleteItem() that was passed down from the Application component. These additions give us everything we need to trigger the method we implemented moments ago. We also add a Remove Unpacked Items button beneath the Mark All as Unpacked button. This button is a lot simpler as it doesn’t need to pass that method along.

Listing 12.11. Passing in delete methods: ./app/components/Application.js
class Application extends Component {
  constructor(props) { ... }

componentDidMount() { ... }
  fetchItems() { ... }
  addItem(item) { ... }
  markAsPacked(item) { ... }
  markAllAsUnpacked() { ... }
  deleteItem(item) { ... }
  deleteUnpackedItems() { ... }

  render() {
    const { items } = this.state;
    const unpackedItems = items.filter(item => !item.packed);
    const packedItems = items.filter(item => item.packed);

    return (
      <div className="Application">
        <NewItem onSubmit={this.addItem} />
        <Items
          title="Unpacked Items"
          items={unpackedItems}
          onCheckOff={this.markAsPacked}
          onDelete={this.deleteItem}               1
        />
        <Items
          title="Packed Items"
          items={packedItems}
          onCheckOff={this.markAsPacked}
          onDelete={this.deleteItem}               2
        />
        <button
          className="button full-width"
          onClick={this.markAllAsUnpacked}>
          Mark All As Unpacked
        </button>
        <button
          className="button full-width secondary"
          onClick={this.deleteUnpackedItems}>      3
          Remove Unpacked Items
        </button>
      </div>
    );
  }
}

export default Application;

  • 1 Passes this.deleteItem() to the Unpacked Items
  • 2 Passes this.deleteItem() to the Packed Items
  • 3 Adds a button to trigger this.deleteUnpackedItems().

The Remove Unpacked Items button should be fully functional at this point. But we still need to keep passing this.deleteItem() along. The next step is to receive it as the onDelete property in the Items component, which powers the Packed Items and Unpacked Items, and then pass it down to each individual item with a reference to the specific item.

Listing 12.12. The Items component: ./app/components/Items.js
const Items = ({ title, items, onCheckOff, onDelete }) => {
  return (
    <section className="Items">
      <h2>{ title }</h2>
      {items.map(item => (
        <Item
          key={item.id}
          onCheckOff={() => onCheckOff(item)}
          onDelete={() => onDelete(item)}
          {...item}
        />
      ))}
    </section>
  );
};

export default Items;

There isn’t a lot that’s new here. I included it for the sake of completeness. We’re almost there, and the next step is to add the button to the individual item. This button should have a click event that triggers the this.deleteItem() method that was passed in from the Application component as the onDelete() command.

Listing 12.13. The Item component: ./app/component/Item.js
const Item = (({ packed, id, value, onCheckOff, onDelete }) => {     1
  return (
    <article className="Item">
      <label>
        <input type="checkbox" checked={packed} onChange={onCheckOff} />
        {value}
      </label>
      <button className="delete" onClick={onDelete}>?</button>       2
    </article>
  );
});

  • 1 Pulls in the onDelete() property using object destructuring
  • 2 Adds the button and set its click event handler to the onDelete() function passed in from the parent

With this last piece, the delete functionality is now in place. We have implemented the ability to create, read, update, and delete items from the database. If the user quits Jetsetter, restarts their computer, and then restarts the application, their items will be in the same state as when they left them. Maybe.

12.1.8. Storing the database in the right place

For the sake of expediency, I had you place the application in the root of the project directory. But this isn’t normally where user-specific data goes. As it stands, this database is shared by all users on a given computer, which is certainly confusing.

On one hand, operating systems have solved this problem for us. Users have designated places where their particular data should be stored. But, each operating system solves this problem in a slightly different way. Luckily, we aren’t concerned about this because Electron protects us. The Electron app module exposes a method called getPath(), which figures out the operating system–specific path for a common location. We’re going to look for the userData path, but you can see a full list of the paths available at (https://electron.atom.io/docs/api/app/#appgetpathname).

Listing 12.14. Storing the database in the appropriate location: ./app/database.js
import * as path from 'path';
import { app } from 'electron';

const database = knex({
  client: 'sqlite3',
  connection: {
    filename: path.join(
      app.getPath('userData'),          1
      'jetsetter-items.sqlite'          2
    )
  },
  useNullAsDefault: true
});

  • 1 Uses Electron’s built-in API for finding the correct path for user data depending on the operating system
  • 2 Gives the database a unique name in development

I tend to give files stored outside of the project unique names to avoid the chance of colliding with another project on my machine. Before branding, all Electron applications are called “Electron.” If I give things a unique name, it won’t matter much in the event they happen to end up in the same folder under the hood.

12.2. IndexedDB

You can store data in an Electron application in many ways. We discussed SQLite earlier, but you could just as easily use a NoSQL database such as LevelDB. You could even just use a JSON file that you write to and read from.

If managing files and recompiling dependencies seems like a bit much for your application, you can opt for browser-based storage. In chapter 2, we used localStorage to track our bookmarks. localStorage is great, but it has some limitations: everything must be stored as a large JSON object of strings. We must parse and resave the object every time we want to read or write to localStorage.

Before we start using IndexedDB, we should cover some terminology. SQL databases contain tables of rows and columns. They’re a lot like incredibly powerful, interconnected spreadsheets. NoSQL databases are typically key-value stores—much like a giant JavaScript object. When working with IndexedDB, you create stores of data, which contain a series of keys. Each key points to an object. IndexedDB differs from SQLite in that keys and values can be any valid JavaScript type—including objects, arrays, maps, and sets.

All interactions with IndexedDB are asynchronous, which makes sense. Natively, however, IndexedDB uses events to handle asynchrony, which can be confusing to read and understand. Luckily, Jake Archibald—a developer advocate at Google—has done the yeoman’s work of wrapping the event-based API with a promise-based API called idb. The abstraction is lightweight and doesn’t hide any of the inner workings of IndexedDB. It’s what I personally recommend, and after you’re comfortable with a promise-based API, it’s easier to wrap your head around than the event-based one. You normally don’t need a library to use IndexedDB, but you do need to import idb if you’d like to use the promise-based API.

12.2.1. Creating a store with IndexedDB

In this example, we start by opening the jetsetter database. The name of this database is completely arbitrary. You could name it eggplant-parmigiana, if that is more appealing to you. We also passed the number 1, which represents the version of our database. Version 1 is a good place to start. (There’s no schema like SQLite!)

Listing 12.15. Setting up a store in IndexedDB: ./app/database.js
import idb from 'idb';

const database = idb.open('jetsetter', 1, upgradeDb => {    1
  upgradeDb.createObjectStore('items', {                    2
    keyPath: 'id',                                          3
    autoIncrement: true                                     4
  });
});

  • 1 Opens up version 1 of the jetsetter database
  • 2 Creates a store for the items in the application
  • 3 We’ll use the id property of the objects to serve as the key.
  • 4 Tells IndexedDB to take care of autoincrementing the id key on our behalf

Versioning is important in that in web applications, each user stores their IndexedDB locally in their browser. If you change the way your code works, it could corrupt the user’s data if they are using an old version of the database.

Having a version number allows you to bump up that number whenever you make a change to how your database works. In that event, you’d be able to migrate the data to be compatible with the changes you’ve made before any other application code accesses the database. We stick with one version of the database in this chapter because our data is very simple.

Typically, you have one database. This is similar to the fact that most smaller and medium-sized, server-side web applications have just one database that they work with in a given environment (e.g., production). But you may have many stores. Think of stores like tables in an SQL database. In this application, we have only one store for items, but you could have a store for people, locations, or any other model in your application.

We created a store called items, which is a reasonable name for a place where we are going to store all of our items. We also passed two options: we set the keyPath to id and autoIncrement to true. What does this mean? It means that if we pass in an item with an id property, it uses that property as the key. If we don’t, then it adds one to the object and sets it as the key, incrementing the number each time so that the key is unique.

12.2.2. Getting data from IndexedDB

We cheated a bit when we implemented SQLite by using Knex.js to eliminate a lot of the tedium. idb is an abstraction over IndexedDB, but it’s more lightweight than Knex.js. This means we have to do a bit more of the manual labor ourselves. There are libraries like localForage (https://github.com/localForage/localForage) that provide higher-level abstractions over IndexedDB, but let’s stick with idb and create our own abstraction.

Here is the approach we take: in ./app/database.js, we create an object with methods to get all of the items from IndexedDB, create new items, update existing items, and delete items. We then use these methods in the Application component, replacing the calls to SQLite as we go. Let’s start by writing a method to get all of the items from the database.

Listing 12.16. Getting all of the items from IndexedDB: ./app/database.js
export default {
  getAll() {
    return database.then(db => {            1
      return db.transaction('items')        2
               .objectStore('items')        3
               .getAll();                   4
    });
  },
  add(item) { },
  update(item) { },
  markAllAsUnpacked() { },
  delete(item) { },
  deleteUnpackedItems() { }
};

  • 1 Accesses the database
  • 2 Starts a transaction and declares that you’ll be working with the items store.
  • 3 Accesses the items store
  • 4 Gets all of the items from the store

I began by exporting an object with all of the methods that we need to match the functionality of SQLite. Most of those methods are empty right now, but we fill them in as we go through the chapter. I did, however, implement the getAll() method.

We start by accessing the database, similar to what we did with Knex.js and SQLite. Next, we start a transaction. Transactions prevent multiple changes to the database from occurring at the same time. If something blows up in a transaction, all of the changes made in the transaction are reverted. This practice protects data from corruption. All interactions with IndexedDB must be wrapped in a transaction. Once inside our transaction, we access the items store and get all of the items from it.

12.2.3. Writing data to IndexedDB

Getting all of the items from IndexedDB isn’t particularly helpful if there aren’t any items in the store to begin with. Let’s implement the ability to add an item to the database. This procedure is similar to reading from the database with a few important distinctions. When reading from the database, the transaction is complete when we finish reading. Modifying the database is a little more complicated. As a result, we need to be more explicit about when a transaction has completed. We also need to let IndexedDB know that we intend to write to the database. After that, it’s a matter of adding the item to the database and then calling the transaction complete.

Listing 12.17. Adding an item to IndexedDB: ./app/database.js
export default {
  getAll() { ... },
  add(item) {
    return database.then(db => {
      const tx = db.transaction('items', 'readwrite');     1
      tx.objectStore('items').add(item);                   2
      return tx.complete;                                  3
    });
  },
  update(item) { },
  markAllAsUnpacked() { },
  delete(item) { },
  deleteUnpackedItems() { }
};

  • 1 Creates a new read/write transaction with the items store
  • 2 Accesses the items store, and adds the item to the database
  • 3 Returns the completed transaction

When we fetched all of the items, we chained the promises together neatly. If this code looks a bit more complicated, don’t worry—it’s similar. We need to return the transaction promise we created on the first line, so we store it in a variable. Inside of the transaction, we add the item passed as an argument to the items store. If we had other work to do, we could do it here. Finally, we return a promise that resolves when the transaction has been completed.

Listing 12.18. Updating an item in IndexedDB: ./app/database.js
export default {
  getAll() { ... },
  add(item) { ... },
  update(item) {
    return database.then(db => {
      const tx = db.transaction('items', 'readwrite');
      tx.objectStore('items').put(item);
      return tx.complete;
    });
  },
  markAllAsUnpacked() { },
  delete(item) { },
  deleteUnpackedItems() { }
};

Updating an item in the database is similar to adding a new item to the database except we use the put() method instead of the add() method. Updating all of them, however, is a little more involved. First, we need to get all of the items from the database. Then we change the packed status of each of them to false. Finally, we create a transaction where we update each item in the database.

Listing 12.19. Marking all items as packed: ./app/database.js
export default {
  getAll() { ... },
  add(item) { ... },
  update(item) { ... },
  markAllAsUnpacked() {
    return this.getAll()                                                1
      .then(items => items.map(item => ({ ...item, packed: false })))   2
      .then(items => {
        return database.then(db => {
          const tx = db.transaction('items', 'readwrite');              3
          for (const item of items) {                                   4
            tx.objectStore('items').put(item);                          5
          }
          return tx.complete;                                           6
        });
      });
  },
  delete(item) { },
  deleteUnpackedItems() { }
};

  • 1 Gets all of the items from the database
  • 2 Sets the packed status of each item to false
  • 3 Creates a transaction for updating the items in the database
  • 4 Iterates over all of the items
  • 5 Updates the item in the database
  • 6 Completes the transaction

If a single update fails, the entire transaction fails, and all of the items return to their original state. The advantage of using transactions is that it ensures you can’t end up in a state where half of your data has been updated and half remains unchanged. Transactions are an all-or-nothing affair.

Deleting items from the database is virtually the same as updating them. We use the IndexedDB delete() method instead of update(). If you want to be clever, you could probably provide an abstraction over the shared pieces of these implementations, but my job as your author is to be clear rather than clever.

Listing 12.20. Removing an item from IndexedDB: ./app/database.js
export default {
  getAll() { ... },
  add(item) { ... },
  update(item) { ... },
  markAllAsUnpacked() { ... },
  delete(item) {
    return database.then(db => {
      const tx = db.transaction('items', 'readwrite');
      tx.objectStore('items').delete(item.id);             1
      return tx.complete;
    });
  },
  deleteUnpackedItems() { }
};

  • 1 Uses the delete method to remove an item from the database

When we implemented markAllAsUnpacked(), we mapped over all of the items, regardless of their status. In implementing deleteUnpackedItems(), we need to be more careful. Our job is to delete only those that have their packed property set to false.

Listing 12.21. Deleting all unpacked items from IndexedDB: ./app/database.js
export default {
  getAll() { ... },
  add(item) { ... },
  update(item) { ... },
  markAllAsUnpacked() { ... },
  delete(item) { ... },
  deleteUnpackedItems() {
    return this.getAll()
      .then(items => items.filter(item => !item.packed))         1
      .then(items => {
        return database.then(db => {
          const tx = db.transaction('items', 'readwrite');
          for (const item of items) {
            tx.objectStore('items').delete(item.id);             2
          }
          return tx.complete;
        });
      });
  }
};

  • 1 Filters out all of the items that have been packed, and returns an array of items that remain unpacked.
  • 2 Deletes each of those items

Unlike SQLite, IndexedDB doesn’t provide support for querying the database, which makes a certain amount of sense considering it is a NoSQL database. When working with NoSQL databases, you can take some interesting approaches, such as storing your data in multiple places with different indices for quick retrieval, but that is a bit outside the scope of this book.

We’re opting for the simplest approach—and one that is a perfect fit for our data set, which is to simply fetch all of the items from the database and find only the ones that meet our criteria. Again, we do this in a transaction. If deleting any of these items fails, the database returns to the state that it was in before we triggered this method.

12.2.4. Connecting the database to the UI

With this last method in place, we need to connect all of the new database methods to the UI. By building the database methods in the previous section, we’ve made transitioning from the SQLite implementation to one based on IndexedDB relatively painless. The main difference is that we aren’t querying for items in the database.

Listing 12.22. Updating the Application component to use IndexedDB: ./app/components/Application.js
  fetchItems() {
    this.props
      .database
      .getAll()
      .then(items => this.setState({ items }))
      .catch(console.error);
  }

  addItem(item) {
    this.props.database.add(item).then(this.fetchItems);
  }

  deleteItem(item) {
    this.props
      .database
      .delete(item)
      .then(this.fetchItems)
      .catch(console.error);
  }

  markAsPacked(item) {
    const updatedItem = { ...item, packed: !item.packed };
    this.props
      .database
      .update(updatedItem)
      .then(this.fetchItems)
      .catch(console.error);
  }

  markAllAsUnpacked() {
    this.props
      .database
      .markAllAsUnpacked()
      .then(this.fetchItems)
      .catch(console.error);
  }

  deleteUnpackedItems() {
    this.props
      .database
      .deleteUnpackedItems()
      .then(this.fetchItems)
      .catch(console.error);
  }

Summary

  • Compiled dependencies work only with the version of the V8 engine used by Node that they were compiled against.
  • The version of Node on your system may differ from the version of Node bundled with Electron.
  • Normally, when you install dependencies using npm install or yarn install, the dependencies are built against the system version of Node—not that of Electron.
  • electron-rebuild goes through your installed dependencies and rebuilds them for the version of Node packaged with Electron.
  • User data should be stored in the appropriate place on the filesystem. This differs between filesystems, but Electron provides a helpful abstraction called app.getPath() that can determine the correct path on your behalf.
  • SQLite is a common choice for native applications because it stores data in a file instead of requiring that a database server be installed and running.
  • IndexedDB is a popular browser-based option for storing user data on the client. It is a NoSQL database provided by Chromium.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.217.182.45