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:
- 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. - 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. - Add a "title" field with the type "string" and any value to represent a task. I used "Do a thing" as the test title.
- 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.
- 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!