Doug Waltman

Firebase + Gatsby

Gatsby is a powerful tool, letting us quickly generate static React applications. It's all delivered client side, but sometimes we need a little back-end support. That's where Firebase comes in. Firebase is a cloud based data service, and has a generous free tier plan to get us started.

In this walkthrough, we're going to leverage what we learned from using MobX with Gatsby in my previous blog post. We'll be creating a firestore to keep track of a list of tasks, and using MobX to query and store data locally.

Getting started with Firebase

First off, get yourself setup with a free Firebase account at firebase.google.com.

Adding a firestore

In the Firebase dashboard, go to the "database" section under the "develop" heading. We'll want to first setup a new project; I named mine "gatsby-tasks". Then add a new firestore; I've also named this "gatsby-tasks".

Adding a collection

Firestores can contain multiple collections. We want to create just one for now. Click the "+ Start Collection" link under the "Data" tab of the "gatsby-tasks" firestore. Enter "tasks" as the value in the "Collection ID" field and click "Next".

The second step asks us to create the first document. Let's add some fields with mock data to get us thinking about our data structure:

  1. Add a "createdAt" field with the type "number". (We could take advantage of the "timestamp" type, but using a unix epoch value will be easier to work with in JS). Since this is mock data, any integer will do for the "value". I ran new Date().valueOf() in my browser console and copied the result.
  2. Add a "dueAt" field with the type "number". This is when our task should be completed by. Again, this could be any number, but we could also run new Date().valueOf() + 86400 to get a time one day into the future.
  3. Add a "title" field with the type "string" and any value to represent a task. I used "Do a thing" as the test title.
  4. Add a "status" field with the type "string". Firestore's don't have an enumerated type option, so we will be responsible for safely coding status strings in Gatsby. We'll be using "pending", "active" and "completed" in our app. Add "pending" as the value for this test document.
  5. Click "Save"

We now have a collection called "tasks" with a single document.

Creating an index

Indexes are necessary when we want to sort document queries.

Go to the indexes tab and click the "Add Index" button. Enter in "tasks" as the collection. Composite indexes must have at least two fields. Add "status" (ascending) as the first field and "dueAt" (descending) as the second field.

Scroll down and select the "collection" option under "Query Scopes" and then hit the "Create Index" button. This can take a minute or two to run, so grab a coffee and meet me back here when its done.

Adding Firebase to Gatsby

First, let's get the modules we'll be using, "dotenv", "firebase" and "gatsby-plugin-firebase". Run yarn add dotenv firebase gatsby-plugin-firebase.

Add the "gatsby-plugin-firebase" configuration to "gatsby-config.js":

plugins: [
  // other plugins...
  {
    resolve: 'gatsby-plugin-firebase',
    options: {
      features: {
        firestore: true,
      },
    },
  },
],

Adding environment variables

"dotenv" is a module that will let us provide environment variables to Gatsby. Note that by default, Gatsby will only let us access environment variables that start with "GATSBY_". I recommend the "gatsby-plugin-env-variables" plugin if you're planning to use env vars that don't have the prefix.

Create a .env file in your Gatsby project's main directory. In order to use the "gatsby-plugin-firebase" plugin, we need to get the SDK values for our application. Click the gear icon next to "Project Overview" in the Firebase dashboard and select "Project Settings". Scroll down to the "Firebase SDK snippet" section and copy the variables into the .env file:

GATSBY_FIREBASE_API_KEY=
GATSBY_FIREBASE_AUTH_DOMAIN=
GATSBY_FIREBASE_DATABASE_URL=
GATSBY_FIREBASE_PROJECT_ID=
GATSBY_FIREBASE_STORAGE_BUCKET=
GATSBY_FIREBASE_MESSAGING_SENDER_ID=
GATSBY_FIREBASE_APP_ID=

Then include "env" at the top of gatsby-config.js.

require('dotenv').config({ path: '.env' })

Adding firestore to a MobX store

As mentioned in the intro, this walkthrough assumes you have MobX setup in your Gatsby project already. With that assumption made, let's create a "tasks" store:

// src/stores/tasks-store.js

import { observable, action, decorate, toJS } from 'mobx'

class TasksStore {
  firestore = null
  tasks = []

  setFirestore(firestore) {
    this.firestore = firestore
  }

  getTasks() {
    // we'll set this up later
  }

  addTask(task) {
    // we'll set this up later
  }

  updateTask(task) {
    // we'll set this up later
  }

  dehydrate() {
    return {
      firestore: this.firestore,
      tasks: this.tasks,
    }
  }
}

decorate(TasksStore, {
  firestore: observable,
  tasks: observable,
  setFirestore: action,
  getTasks: action,
  addTask: action,
  updateTask: action,
})

export default TasksStore

Provide the new store

// provide-stores.js

import React from 'react'
import { Provider } from 'mobx-react'
import TasksStore from './src/stores/tasks-store'

export default ({ element }) => (
  <Provider tasks={new TasksStore()}>
    {element}
  </Provider>
)

Initialize the firestore

This is where this get a little tricky. If we were to initialize the firestore in provide-stores.js by providing "firestore" as a constructure (e.g. <Provider tasks={new TasksStore({ firestore })}>), Gatsby would throw an error and refuse to start. We need to initialize on the client side after the app has started.

Let's take advantage of the useMemo hook to set the firestore in our TasksStore using the default layout. I like to keep layouts in their own folder, and for this example, I put it in src/layouts/default.js. Here's the barebones layout setup:

// src/layouts/default.js

import React, { useContext, useMemo } from 'react'
import { inject } from 'mobx-react'
import { FirebaseContext } from 'gatsby-plugin-firebase'

const DefaultLayout = ({ tasks: tasksStore, children }) => {
  // get the firebase context from the plugin
  const firebase = useContext(FirebaseContext)

  useMemo(() => {
    if (!firebase) return
    // once firebase is initialized, we can add it to our store
    tasksStore.setFirestore(firebase.firestore())
  }, [firebase])

  return <div>{children}</div>
}

export default inject('tasks')(DefaultLayout)

Adding API calls

Now let's update our store's actions, "getTasks", "addTask", and "updateTask" with async+await functions that use our firestore client.

getTasks

// src/stores/tasks-store.js

async getTasks() {
  // use a try+catch block in case things explode
  try {
    // fetch the tasks from our firestore
    const { docs } = await this.firestore
      .collection('tasks')
      .orderBy('dueAt', 'asc')
      // in the real world, we'd need to handle pagination
      .limit(100)
      .get()
    // map the document data to a tasks array
    const tasks = docs.map(doc => ({
      id: doc.id,
      ...doc.data(),
    }))

    // update the tasks observable
    this.tasks.replace(tasks)

  } catch (error) {
    // in the real world, we'd want to capture this error and display a message to users
    console.log(error)
  }
}

addTask

// src/stores/tasks-store.js

async addTask(task) {
  try {
    const ref = await this.firestore.collection('tasks').add({
      ...task,
      // the default status is "pending"
      status: 'pending',
      // use a linux timestamp value for "createdAt"
      createdAt: new Date().valueOf(),
    })
    // fetch the document we just added
    const doc = await ref.get()

    // convert the task observable to a plain array so we can sort it
    const tasks = toJS(this.tasks)

    // add the new task
    tasks.push({ id: doc.id, ...doc.data() })

    // sort by the dueAt time
    tasks.sort((a, b) => (a.dueAt > b.dueAt ? 1 : -1))

    // update the observable
    this.tasks.replace(tasks)

    // For the sake of simplicity, we're returning a boolean response, though in the real world, we could be better served with a response object, such as { success: true }
    return true
  } catch (error) {
    // And here, an object response might look like { success: false, error }
    console.log(error)
    return false
  }
}

updateTask

// src/stores/tasks-store.js

async updateTask(task) {
  // separate the task object from its "id"
  const { id } = task
  delete task.id

  try {
    // get the document reference
    const ref = this.firestore.collection("tasks").doc(id)

    // update the task
    await ref.update(task)

    // fetch the document we just updated
    const doc = await ref.get()

    // find the task we just updated and store the new values
    const storedIdx = this.tasks.findIndex(t => t.id === id)
    this.tasks[storedIdx] = { id, ...doc.data() }

    // For the sake of simplicity, we're returning a boolean response, though in the real world, we could be better served with a response object, such as { success: true }
    return true
  } catch (error) {
    // And here, an object response might look like { success: false, error }
    console.log(error)
    return false
  }
}

Putting it all together

Our tasks store looks like this now:

// src/stores/tasks-store.js

import { observable, action, decorate, toJS } from 'mobx'

class TasksStore {
  firestore = null
  tasks = []

  setFirestore(firestore) {
    this.firestore = firestore
  }

  async getTasks() {
    try {
      const { docs } = await this.firestore
        .collection('tasks')
        .orderBy('dueAt', 'desc')
        .limit(100)
        .get()
      const tasks = docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      }))

      this.tasks.replace(tasks)
    } catch (error) {
      console.log(error)
    }
  }

  async addTask(task) {
    try {
      const ref = await this.firestore.collection('tasks').add({
        ...task,
        status: 'pending',
        createdAt: new Date().valueOf(),
      })
      const doc = await ref.get()
      const tasks = toJS(this.tasks)

      tasks.push({ id: doc.id, ...doc.data() })
      tasks.sort((a, b) => (a.dueAt > b.dueAt ? 1 : -1))
      this.tasks.replace(tasks)
      return true
    } catch (error) {
      console.log(error)
      return false
    }
  }

  async updateTask(task) {
    const { id } = task
    delete task.id

    try {
      const ref = this.firestore.collection("tasks").doc(id)
      await ref.update(task)

      const doc = await ref.get()
      const storedIdx = this.tasks.findIndex(t => t.id === id)

      this.tasks[storedIdx] = { id, ...doc.data() }
      return true
    } catch (error) {
      console.log(error)
      return false
    }
  }

  dehydrate() {
    return {
      firestore: this.firestore,
      tasks: this.tasks,
    }
  }
}

decorate(TasksStore, {
  firestore: observable,
  tasks: observable,
  setFirestore: action,
  getTasks: action,
  addTask: action,
  updateTask: action,
})

export default TasksStore

Using the new tasks store

Let's create a component to display the list of tasks. We'll take advantage "useState" to display a loading status and "useEffect" to trigger a render once tasks have been fetched. We also want to format the "dueAt" integer as a date. I'm using the "date-format" module, but feel free to use your formatter of choice.

Create a TaskList component

// src/components/task-list.js

import React, { useEffect, useState } from 'react'
import { inject, observer } from 'mobx-react'
import dateFormat from 'date-format'

const TaskList = ({ tasks: tasksStore }) => {
  const [isLoading, setIsLoading] = useState(true)
  const { tasks, firestore } = tasksStore

  useEffect(() => {
    // wait for firestore to be initialized
    if (!firestore) return
    // avoid race conditions
    let didCancel = false

    const getTasks = async () => {
      await tasksStore.getTasks()
      if (!didCancel) setIsLoading(false)
    }
    getTasks()
    return () => (didCancel = true)
  }, [firestore])

  if (isLoading) return 'Loading tasks...'
  return (
    <ul>
      {tasks.map(({ id, title, dueAt }) => (
        <li key={id}>
          <h3>{title}</h3>
          <time>
            {dateFormat('MM/dd/yyyy h:mm', new Date(dueAt))}
          </time>
        </li>
      ))}
    </ul>
  )
}

export default inject('tasks')(observer(TaskList))

Create form to add new tasks

We're going to utilize "useState" to handle field value changes, "useRef" to reset the title input after submission, and "useEffect" to detect when the firestore client is ready. In this example, I'm also using the "react-datepicker" module, but feel free to use whichever date picker you like the most.

// src/components/new-task-form.js

import React, { useRef, useEffect, useState } from 'react'
import { inject, observer } from 'mobx-react'
import DatePicker from 'react-datepicker'

import 'react-datepicker/dist/react-datepicker.css'

const NewTaskForm = ({ tasks: tasksStore }) => {
  const { firestore } = tasksStore
  const titleRef = useRef(null)
  const [title, setTitle] = useState('')
  const [dueDate, setDueDate] = useState(new Date())
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [isInitialized, setIsInitialized] = useState(false)
  const [message, setMessage] = useState('')

  const handleTitleChange = event =>
    setTitle(event.target.value.trim())

  const handleDueDateChange = date =>
    setDueDate(date)

  const handleSubmit = async event => {
    event.preventDefault()
    // avoid double submissions
    if (isSubmitting) return
    setIsSubmitting(true)

    // add the new task
    const success = await tasksStore.addTask({
      title,
      dueAt: dueDate.valueOf(),
    })

    if (success) {
      // clear the title field and add a success message
      titleRef.current.value = ''
      setMessage('Task added!')
    } else {
      // set an error message on fail
      setMessage('An error occurred adding the task.')
    }
    setIsSubmitting(false)
  }

  // wait for the firestore to be ready
  useEffect(() => {
    if (!firestore) return
    setIsInitialized(true)
  }, [firestore, setIsInitialized])
  
  if (!isInitialized) return null
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="title">Title</label>
        <input
          ref={titleRef}
          id="title"
          name="title"
          onChange={handleTitleChange}
        />
      </p>
      <p>
        <label>Due Date</label>
        <DatePicker
          onChange={handleDueDateChange}
          selected={dueDate}
          showTimeSelect
        />
      </p>
      <p>
        <button
          type="submit"
          disabled={!title || isSubmitting}
        >
          Add Task
        </button>
      </p>
      {message && <p>{message}</p>}
    </form>
  )
}

export default inject('tasks')(observer(NewTaskForm))

Testing it out

Let's see all of our work in action. Add the "TaskList" and "NewTaskForm" components to /src/pages/index.js:

// src/pages/index.js

import React from 'react'
import Layout from '../layouts/default'
import TaskList from '../components/task-list'
import NewTaskForm from '../components/new-task-form'

const IndexPage = () => (
  <Layout>
    <TaskList />
    <NewTaskForm />
  </Layout>
)

export default IndexPage

Now fire up the server and we should see the task we initialized our firestore with, as well as a simple form for adding new tasks. Try it out and then meet me back here for the final steps.

Updating task statuses

We want to be able to mark tasks as complete. Let's add a "Mark Complete" button to each task, which will call the "updateTask" method in our tasks store.

We want to keep our compenents simple and separate concerns, so first let's update our "TaskList" component.

// src/components/task-list.js

import React, { useEffect, useState } from 'react'
import { inject, observer } from 'mobx-react'
import TaskListItem from './task-list-item'

const TaskList = ({ tasks: tasksStore }) => {
  const [isLoading, setIsLoading] = useState(true)
  const { tasks, firestore } = tasksStore

  useEffect(() => {
    if (!firestore) return
    let didCancel = false

    const getTasks = async () => {
      await tasksStore.getTasks()
      if (!didCancel) setIsLoading(false)
    }
    getTasks()
    return () => (didCancel = true)
  }, [firestore])

  if (isLoading) return 'Loading tasks...'
  return (
    <ul>
      {tasks.map(task => (
        <TaskListItem key={task.id} task={task} />
      ))}
    </ul>
  )
}

export default inject('tasks')(observer(TaskList))

And now let's add the "TaskListItem" component to src/components/task-list-item.js.

We're going to display the task status so that we can see it update when we click the "Mark Complete" button. We're going to utilize "useState" to keep track of when the request is being made and disable the button when "isBusy" is true. Finally, we will check for the existence of "firestore" in the tasks store and disable the button until it is initialized.

// src/components/task-list-item.js

import React, { useState } from 'react'
import { inject } from 'mobx-react'
import dateFormat from 'date-format'

const TaskListItem = ({ tasks: tasksStore, task }) => {
  const [isBusy, setIsBusy] = useState(false)
  const { title, status, dueAt } = task
  const { firestore } = tasksStore

  const handleMarkComplete = async () => {
    setIsBusy(true)
    // update the task with the new status
    await tasksStore.updateTask({
      ...task,
      status: 'complete',
    })
    setIsBusy(false)
  }

  return (
    <li>
      <h3>{title}</h3>
      <div>
        <time>
          Due {dateFormat('MM/dd/yyyy', new Date(dueAt))}
        </time>
      </div>
      <div>
        <mark>{status}</mark>
        {status !== 'complete' && (
          <button
            disabled={!firestore || isBusy}
            onClick={handleMarkComplete}
          >
            {isBusy ? '...' : 'Mark Complete'}
          </button>
        )}
      </div>
    </li>
  )
}

export default inject('tasks')(TaskListItem)

Now fire up Gatsby again and click the "Mark Complete" button on any task. We should see the "pending" status change to "complete". Awesome!

Closing thoughts

We've learned how firestores let us connect a backend to Gatsby applications with minimal effort. We created a simple proof of concept task list application and learned how to configure Gatsby to use Firebase on the client side. From here, the rest is up to your own imagination. Improve the UI, add new features, and as always, continue learning and growing.

Catch you all in a future ad-free blog post, right here with me. Cheers!