Overview

Google's Firebase is a suite of services that make it possible to build multi-user data-rich web apps without creating a web server of your own. The level of service for free is relatively generous and more than adequate for most prototype projects.

Northwestern Google accounts do not support Firebase. To create a Firebase project, you need to use a personal Google account, or create one if necessary.

The Firebase CLI

The Firebase command line interface is the best way to set up your app to talk to Firebase. See these instructions on how to install the Firebase CLI on your local machine. Install the npm CLI rather than the standalone binaries, so that every team member is working with the same interface, whether Window, MacOS, or Linux.

On your local machine, test your installation with

firebase login
firebase list

If your machine is not already logged onto Firebase, you will be asked to provide your username and password. Then the list command will show what projects you have, if any.

The most common Firebase CLI commands you will use are

  • firebase init to set up and configure different Firebase services for your app
  • firebase deploy to upload your app to the Firebase web host, after you do npm run build

Creating a project on Firebase

A "project" on Firebase is a set of resources and services that can be used by one or more apps. You create a Firebase project with your browser on the Firebase web console. Follow these instructions.

For prototyping, the most common services to set up are the Realtime Database, web hosting, authentication, and, if you need to store images, Firestore.

To set up a Realtime Database, cick on the Database link on the left. Firebase will offer two options: FireStore and Realtime Database. Pick the Realtime Database for normal data.

Firebase will ask if you want the Realtime Database in locked or test mode. Pick test. It's not secure but it will let you start writing code to read and write data without writing security rules. Firebase will send you emails about this until you fix those rules later.

If you have any data you want to initialize your database to for testing, make a legal JSON file with the data, and import it into the database.

If this is for a team project, be sure to add your team members to the project. See these instructions.

Adding Firebase to a React app

In a command shell, switch into the directory for your app. Then do

firebase init

This will ask you a series of questions. These change every few months so read carefully. When in doubt, hit enter to accept the default. The important things to pay attention to are the following:

  • What Firebase features do you want? Pick
    • Database for the Realtime Datasbase
    • Hosting if you want to deploy your web app onto the Firebase server
    • Firestore if you want to store images
    You can run firebase init again later to add services.
  • What Firebase project to connect to? Pick the one you created. If you don't see it, follow these instructions.
  • What is your public directory? Enter dist. Do not accept the default value "public".
  • Is this a single page webapp? Say Yes.
  • Overwrite dist/index.html? Say No.
  • Add Github Integration. Say No. If you need to do this, see these instructions.

If you forget to say dist for the public directory, Firebase will not know where your React app is. You can fix this mistake by editing the file firebase.json. Look for the key public in the file and change the value from "public" to "dist". Save, build (with npm) and deploy.

Next you need to install the Firebase code library for your app.

npm install firebase

Now any code file that needs to call Firebase functions, such as firebase.js, can import that code with

import firebase from 'firebase/app';
import 'firebase/database';

Don't forget that Firebase must be initialized when your app first starts, with

const firebaseConfig = {
  apiKey: "api-key",
  authDomain: "project-id.firebaseapp.com",
  databaseURL: "https://project-id.firebaseio.com",
  projectId: "project-id",
  storageBucket: "project-id.appspot.com",
  messagingSenderId: "sender-id",
  appID: "app-id",
};

firebase.initializeApp(firebaseConfig);

The config data can be retrieved at any time from the Firebase console. See these instructions.

None of this data needs to be kept secret. It's OK for this code to be in a file stored on Github.

Debugging Firebase problems

Deployment problems

There are a few common problems that arise when trying to deploy to Firebase hosting. Often there are no error messages, just a failure for changed code to appear on the Firebase site.

Public vs build

By default, the deploy command uploads the directory public to Firebase. But that's not where React tools put production code. If you failed to specify the correct directory when running firebase init, deploy will upload the wrong code.

Open the file firebase.json in your editor. If it says

"hosting": {
  "public": "public",
  ...

change it to "public": "dist". This is where Vite puts production code.

Re-build before re-deploy

The local server managed by npm run start is updated every time a file is changed, but the dist directory is updated only when you run npm run build. So you must remember to do that before calling firebase deploy.

Realtime Database pitfalls

The Realtime Database is conceptually very simple, but it is not like SQL databases in a number of ways

  • Data is in a single big JSON object, not a set of tables and rows. This is simple to say, but tricky to implement properly.
  • You don't query for data. You subscribe to changes in data.
  • Do not use the Realtime Database for binary objects such as images. For that, use Firestore.
  • By default, Firebase creates the database in locked mode. That means that no program can read or modify your data. For initial testing, you need to change it to test mode, where all reading and writing is allowed.
  • With test mode, anyone can read or modify your data if they have the URL. You must define security rules to prevent this. Firebase will email reminders every week until you fix this.
  • There is no error checking when storing data. A simple bug can complete erase all your data. You must write validation rules to prevent bad data from being entered.

Unintended functions directory

Check for a subdirectory called functions in your app directory. This is created if you selected the Functions feature when you ran firebase init. If you have not actually defined any Firebase Cloud functions, this directory will cause errors about a missing node_modules when you deploy. Delete the directory.

Conflicting lock files

Check for yarn.lock file in your app directory. If you have both package-lock.json and yarn.lock in your app directory, Node-based tools, like the Firebase CLI, will get confused. If you are using npm, delete yarn.lock. Use just npm or yarn, not both.

Tracking Firebase calls

Errors in code are always annoying, but errors involving database calls can be lead to major slowdowns, lost data, or unexpected monetary costs. Imagine an endless loop that writes data. Imagine fetching the same data every time a web page is re-rendered.

For that reason, I recommend that you

  • Refactor all code that reads or writes data into one file with a few core functions.
  • Add logging code (to the console or a file) and basic error checking code to those functions.

Only remove or turn off the logging code when your app database interactions haven't needed any changes for weeks.

Reading Firebase data

After Firebase has been initialized, you can fetch your data as one big JSON object with this:

get(ref(database, '/'), snap => {
    if (snap.val()) {
      ...do something with the JSON in snap.val()...
    }
  }
);

This is not great code.

  • It doesn't report errors if they occur. It just silently and frustatingly fails. When you first work on Firebase, you will make many mistakes.
  • It downloads all the data, even if you just need a small subportion of the data.
  • It won't tell you when the data at Firebase changes. Many apps these days are multi-user, so the data can change at any time.

Suppose our data is a JSON object for a baseball app, that has a list of teams, a list of players, and a list of games that includes what teams were playing and what the scores were. Then an app that wants to show current scores should do something like this:

onValue(ref(database, '/games'), snap => {
    if (snap.val()) {
      ...do something with the games in snap.val()...
    }
  }, error => {
    ...do something with the error message...
  }
);

This call to onValue() will download the games data and call the function. snap.val() will be the data, or null if there is no data.

More importantly, the function will be called again, any time any data under the /games path changes. And if there's a problem getting the data, such as a permissions issue the second function will be called, with an error message.

Writing Firebase data

There are four ways to save data to Firebase.

Setting data

The simplest is set(). Follow a path to where you want to store the data, then call set() with the data you want to store. E.g., to store data about a user with the ID userId:

set(ref(database, `users${userId}`).set({
  name: 'Mary Smith',
  email: 'msmith@example.com'
});

Firebase will add the key userId if it's not already in the JSON, and then store the object under it.

Updating fields

If you want to just update one or more fields in some data without affecting any other fields, use update(). E.g., to change a user's email address, but leave any other data about the user alone:

update(ref(database, `users${userId}`), {
  email: 'mary.smith'@example.com'
});

Adding to a list

If you want to add a new object to a list, the first thing to be aware of is that lists are not first-class citizens in Firebase. Anything you store that you want to retrieve should have an unchanging path from the root of the database. Being number 4 in a list is not a stable location.

For adding users, there is aunique user ID. But items in many lists don't have an ID. You can tell Firebase to add an object to a list and create an ID for us with push(). For example, to add a game to our list of games:

const gameRef = push(ref(database, 'games'), {
  date: '5/9/2019'
  teams: ['Cincinnati Reds', 'Oakland Athletics'],
  score: [3, 0]
});

push() with a parent and value returns a reference to the newly added object. Notice that it is OK to use arrays, as this code does, as long as the data will be retrieved as a whole unit.

Handling concurrent updates

set() and update() are dangerous if multiple users might be updating the same data. push() does not have this problem. If two users add a game at exactly the same time, one will get pushed first, then the other.

But suppose you want to let users "like" a team. We might try to increment it with this code:

update(ref(database, `teams${team.id}`), {
  likes: team.likes + 1
});

But if other users have just liked the team and our code hasn't gotten the updates yet, then you will be adding 1 to the old count, not the new one.

To handle this, use runTransaction() instead of set().

You pass transaction() or runTransaction() a function. That function will be called with the most current data at the location you are trying to update. It will normally be the data you have locally. But it might be something that was added that your code has not received yet. It might even be null, so be prepared to handle that by using the local data you have.

Your function should return the new value to store. Firebase will check to see if the data in the cloud is different from what it passed to your function. If so, it will call your function again. If not, it will store your value. In this way, your update function always has the most current value, no matter what order concurrent updates happen in.

So, to properly update our "likes" counter for a team, we should write this:

runTransaction(ref(database, `teams${team.id}`), likes => 
  (likes || 0) + 1
);

The OR expression (likes || 0) is used to return 0 if the likes value happens to be null.

To cancel a transaction, return undefined, i.e., call return with no arguments.

This example of counting likes lets users like things as many times as they want. This Firebase example shows how to avoid that, if you have the ID of the authenticated user.

Updating state and data

Most of the time, when you save date to a database, you will also need to update some local state with the same information. For example, a user action might update a score that you want to save in a database but also display in the user interface.

Since a global side effect is involved, useEffect() is needed. There are two ways to coordinate updating the score.

  • Change the score state, and let a function in useEffect() store the score in the database.
  • Store the score in the database and let a Firebase subscription, created in useEffect(), update the state.

The subscription method is better. It handles updates to shared data involving multiple users, including when a user is logged in twice on two different browsers.

Whatever you do, don't do both methods! You'll get an endless loop of updates.

Debugging Firebase locally

You can run a local Firebase database to test your code without touching the real database your deployed application is using.

This is new technology, likely to change in parts. See the documentation for current details.

Install the Firebase emulators

In a command shell, in your project directory, execute

firebase init emulators

This presents a series of menu choices, similar to firebase init. The only emulator you need for now is the realtime database. Accept the default answers for the other questions asked.

To run the emulator:

firebase emulators:start

This will start all installed emulators.

To see the database, open the URL printed when the emulators started. This is typically http://localhost:4000/database.

You should see an empty database.

An error that the database port is already taken usually means a prior run of the emulator did not halt completely. To clear it, you need to kill the emulator Java process. Use the Linux command lsof to get the ID of the emulator process that is listening on port 9000:

lsof -nP -iTCP:9000 | grep LISTEN
java    57199 riesbeck   22u  IPv6 0xe8f25e7ccff719d      0t0  TCP 127.0.0.1:9000 (LISTEN)

Then use the Linux command kill to kill that process:

kill -9 57199

Set up testing data

The local database will be created fresh every time you run the emulator. To have it start with sample data for testing, first create the data in your emulator. You could do this manually with the browser interface, but a better option is to create a JSON file, and then import it with the browser interface.

When the database has what you want, with the emulators still running, open a new command shell in your project, and execute

firebase emulators:export ./src/saved-data

This will store the data, and any security and validation rules you have created, in the directory src/saved-data. You can use any directory you want. You could create multiple data directories, with different test sets.

Now stop the emulator. Start it with

firebase emulators:start --import=./src/saved-data

Use the browser to inspect the database. It should have the data you exported.

To make future emulation easy, add this script to your package.json

"scripts": {
  "start": "vite",
  "build": "vite build",
  "serve": "vite preview",
  "test": "vitest --ui",
  "coverage": "vitest run --coverage",
  "em:exec": "firebase emulators:exec --ui --import=./saved-data 'npm start'"
},

Then you can start the emulators with

npm run em:exec

Configure your app for local data

The final step is to make your application use the local database when you are in development mode.

In React, you test using the local Webpack server. You can detect this in your JavaScript code by seeing if the hostname in the current URL is localhost. Use that to select the appropriate database URL, like this:

const dbURL = window.location.hostname === 'localhost' 
  ? 'http://localhost:9000?ns=YOUR_PROJECT_ID'
  : 'https://YOUR_PROJECT_ID.firebaseio.com';

const firebaseConfig = {
  ...
  databaseURL: dbURL,
  projectId: "YOUR_PROJECT_ID",
  ...
};

firebase.initializeApp(firebaseConfig);

In Expo / React Native, the variable __DEV__ is set to true in development mode. Use that to select the appropriate database URL, like this:

const dbURL = __DEV__ 
? 'http://localhost:9000?ns=YOUR_PROJECT_ID'
: 'https://YOUR_PROJECT_ID.firebaseio.com';

const firebaseConfig = {
...
databaseURL: dbURL,
projectId: "YOUR_PROJECT_ID",
...
};

firebase.initializeApp(firebaseConfig);

Test

If you've done the above, then when you are developing code, you can run your application with the emulated database with these commands:

    npm run emulate
    npm start
  
Note: your application will hang until you start the emulators.

Firebase Data Design

JSON is not the same as a JavaScript object

The rules for JSON are stricter. The following is a legal JavaScript object, but not legal JSON:

{ id: "jsmith", email: "john.smith@gmail.com" }

In JSON, keys must strings, so you need to write

{ "id": "jsmith", "email": "john.smith@gmail.com" }

When writing JSON by hand, use a JSON validator to avoid annoying Firebase errors.

When #saving data to Firebase, Firebase will convert your JavaScript object to legal JSON. But if you are importing a JSON file to initialize a database, it needs to follow the rules above.

Arrays are not first-class citizens in Firebase

This may seem unintuitive. Arrays are first-class in JSON. And isn't a database at heart an array of objects? In Firebase, the answer is no.

A Firebase database is key-value pairs, where values are strings, numbers, and nested key-value pair objects. Keys play a prominent role. For example, if you had a list of users, with ids and emails, a common JSON representation would be

{
  "users": [{
    "id": "jsmith",
    "email": "john.smith@gmail.com"
  }, {
    "id": "mjones",
    "email": "mary.jones@.gmail.com"
  }]
}

This is not good Firebase data design. Instead of an array of objects, use an object with appropriate keys for each value, like this:

{
  "users": {
    "jsmith": { "email": "john.smith@gmail.com" },
    "mjones": { "email": "mary.jones@.gmail.com" }
  }
}

Another example might be if you have a list of messages. Don't make an array of them. Make an object whose keys are the message ID or message timestamp.

You can have an array of primitives, e.g., a list of numbers or a list of email addresses, but think about whether these really should have more contentful keys than just "0", "1", and so on.

When working with JSON structured like the above, the JavaScript methods Object.keys(), Object.values(), and Object.entries() are incredibly helpful. For example, if the variable json contains the flattened user data example above, then this JSX expression would calculate an array of "mailto" links, suitable for inserting into a web page:

Object.entries(json.users).map(([id, user]) => (
  <a href={`mailto:${user.email}`}>{id}</a>
));

Readings on Firebase data design

Security rules

Security rules are needed to prevent unauthorized changes of data. It's trivial to reverse engineer how the JavaScript in a web page calls Firebase. The only way to keep a user from changing data that is not theirs is to write security rules.

When you first create a database, it's common to use these rules:

{
"rules": {
  ".read": true,
  ".write": true
}

These rules let any user change in a data. This is fine when the only users are the developer team, but an invitation to disaster in production.

Adding security

The first thing to do is look at your actual data, and identify what parts different users should be able to read or write. As a simple example, suppose you have a database of teams.

{
  "teams": {
    "blue": { "name": "Blue Devils" },
    "green": { "name": "Green Ghosts" },
    "red" { "name": "Red Wings" }
  },
  "users": {
    "jsmith": { "email": "john.smith@gmail.com", "team": "green" },
    "mjones": { "email": "mary.jones@.gmail.com", "team": "blue" },
    "bbrown": { "email": "bill.brown@.gmail.com", "team": "blue" },
  },
  "admins": {
    "mjones": true
  }
}

Firebase data is accessed by paths, e.g., users gets all the users, and teams/blue/name gets the full name of the Blue team. You define security rules by the paths that a user can and can't use to read and write data.

Here's a table of security rules, in English, and in path terms.

Rule in EnglishPath equivalent
Anyone can see the list of teams path teams is readable, even if not authenticated
Only authenticated users can see the user data path users should only be readable for authenticated users
A user can change their personal data path users/<user_id> should only be writable for the user with that user id
Admins can read and write anything the empty root path is writable by an authenticated user who is an admin

Notice that the second last rule has a variable element in it.

You implement these rules in Firebase in the file database-rules.json in your Firebase project. The rules are given as a JSON tree. Here are rules for the above policies:

{
  "rules": {
    ".read": "auth != null && root.child('admins').hasChild(auth.uid)"
    ".write": "auth != null && root.child('admins').hasChild(auth.uid)"
    "teams": {
      ".read": true
    },
    "users": {
      ".read": "auth != null",
      "$user_id": {
        ".write": "auth !== null && author.uid === $user_id"
      }
    }
  }
}

When your code sends a query to Firebase, Firebase follows the keys in the query path down this rule tree, as follows:

  • If the key is in the rules JSON, get the object. If there is a $ wildcard variable like $user_id, set the variable to the path key, and get the object. If the key is not in the JSON, stop and deny access.
  • If the operation is a read and the rules object found has a ".read" key, or the operation is a write and the rules object has a ".write" key, evaluate the expression for the key.
    • If it is true, stop and allow access.
    • Otherwise use the next key in the query path.
  • If there are no more keys in the path, deny access.

The expressions for ".read" and ".write" keys are either true, false, or a string containing a JavaScript expression that evaluates to true or false. The expressions can refer to wildcard variables and predefined variables. The rules above use the predefined variable auth. That holds the authenticated user, if any. auth.uid is the user's ID. The rules also use the predefined variable root. That holds a database reference. root.child(path) can be used to look up data about the user, among other things.

Here's how the above rules work for different paths for different authenticated users:

PathUserRead?Write?
teams none true false
teams jsmith true false
teams mjones true true
users none false false
users jsmith true false
users/jsmith none false false
users/jsmith jsmith true true
users/jsmith bbrown true false
users/jsmith mjones true true

For more on the kinds of rules you can write, see the documentation.

Debugging security rules

There are two ways to test and debug your security rules. For automated testing, you write unit tests Node with the @firebase/testing module. That defines an object that lets you load database rules into a local emulated database. For details, see this documentation.

You can do manual testing with the Rules Playground under the Rules section of the Firebase database dashbboard. This lets you define rules and test paths before saving the rules. You can test with and without an authenticated user.

Readings on security rules

Validation rules

Rules can also be used to validate data before it is stored. This is needed to prevent data corruption. Buggy code can easily corrupt or completely erase data. Once the JSON tree is corrupted, recovery is often impossible. Lost data is lost. There is no undo.

The rules below add some basic validation to avoid storing empty data in our team database, using the predefined variable newData. newData holds the data that is going to be stored at a location.

    "rules": {
      ".read": "auth != null && root.child('admins').hasChild(auth.uid)"
      ".write": "auth != null && root.child('admins').hasChild(auth.uid)"
      "teams": {
        ".read": true,
        "$team_id": { 
          "name": "newData.isString() && newData.val().length > 0"
        }
      },
      "users": {
        ".read": "auth != null",
        "$user_id": {
          ".write": "auth !== null && author.uid === $user_id",
          "email": "newData.isString() && newData.val().length > 0",
          "team": "newData.isString() && root.child('teams').hasChild(newData.val())"
        }
      }
    }
  }

These additions make sure that

  • a team name must be a non-empty string
  • a user's email must be a non-empy string
  • the team given for a user must be one of the teams in the database

A Firebase write operation will fail if it any of these tests returns false.

Readings on validation rules

© 2024 Chris Riesbeck
Template design by Andreas Viklund