Firebase Epic and Reducer Helpers for Redux and RxJS

5 years ago

Summary

If you're using RxJS with Redux, it is more than likely that you are using redux-observable, the middleware library created by a couple of smart developers at Netflix. Coupled with the power of RxJS, it allows you to seamlessly include side-effects to your Redux state architecture. While there are additional libraries that help integrate with Firebase, adding another level of abstraction really isn't necessary since Firebase already provides a powerful library with tons of great documentation. The following is an example of how you can implement real-time CRUD operations using Firebase within your Redux application.


Normalizing Your Data

Both Firebase and Redux have some great recommendations around normalizing or flattening your data. If you already use an existing data schema that is complex or has a lot of nesting, you can potentially handle this in the middle tier at an API level. If you're on the client side of things, you can use destructuring techniques or with libraries like Normalizr. Either way, making your data less complex and flat as possible, will make your life easier on the front-end. While Firebase can be nested to 32 level's deep, this example assumes you are no more than 3 level's deep, which is what most recommend.

Screenshot

This is how the data looks in this example:


Mixing Metaphors?

I struggled a bit with how I should name my data structures as I wanted something descriptive, yet still agnostic to where the data was coming from. Firebase uses the term JSON tree to define it's structure as it's almost infinitely nestable, but that didn't make much sense from a front-end perspective. While I really liked MongoDB's use of collection, the term document was too ambiguous considering other things, especially the DOM, on client-side. I also didn't want to use a relational database term like table or row. Ultimately, I settled on recordset, record, key, and property, which will hopefully be self-explanatory. Here is a high-level comparison of how I am using the terms:

  • MongoDB - collection, document, _id, field
  • Oracle / SQL - table, row, primary key, column
  • This Example: recordset, record, key, property

Code

Note: I'm highlighting only the primary files here, so checkout the full source code in the link below.

reducers/firebaseEpicHelper.js

These helper methods allow you to easily create epics by passing a minimal number of arguments: namely the action stream (designated as action$), and actionType. For getRecordSet, the isAsync flag is used to update your recordSet state in real-time in case you have that requirement. From a simplified data access standpoint, the following three functions will allow you to do most of what you need to do in Firebase:

  • getRecordSet - used for getting a recordSet given a key. Much of the Firebase API can be accessed via Promises, so these can easily be wrapped in an Observable.fromPromise. For the real-time stuff, the Firebase API uses WebSockets under the covers, and this can be consumed using firebaseRef.on('value', snapshot => {}). For this case, we create a custom observable, which takes an observer and allow us to call next and pass our value.
  • removeRecord - removes a record given a record key.
  • saveRecord - adds or updates a record (the term upsert was tempting). You could opt to add logic like generating a new id for new records or adding a created or modified date property.

When each function finish, they run concatPostActions for any postActions that have been defined in our actions. At the minimum this is our reducer, but we could also use this to kick off things like logging or updating state to cancel a loader image.

import { Observable } from 'rxjs/Observable'
import 'rxjs/add/observable/concat'
import 'rxjs/add/observable/fromPromise'
import 'rxjs/add/observable/of'
import 'rxjs/add/operator/catch'
import 'rxjs/add/operator/map'
import 'rxjs/add/operator/switchMap'

import { APP } from '../config/constants'
import { firebaseRef } from '../config/firebase'

const concatPostActions = action => {
  const { postActions, ...noActions } = action
  return Observable.concat(
    postActions.map(postAction => ({
      ...action,
      noActions,
      ...postAction
    }))
  )
}

const firebaseEpicHelper = {
  getRecordSet: (action$, actionType, isAsync) => {
    return action$
      .ofType(actionType)
      .switchMap(action => {
        let observable = null
        if (action.isAsync) {
          observable = Observable.create(observer => {
            firebaseRef(action.recordSetKey).on('value', snapshot => {
              observer.next(snapshot.val())
            })
          })
        } else {
          observable = Observable.fromPromise(firebaseRef(action.recordSetKey).once('value')).map(results =>
            results.toJSON()
          )
        }
        return observable.switchMap(results => {
          action.recordSetKeyValue = results
          return concatPostActions(action)
        })
      })
      .catch(error => Observable.of({ type: APP.LOG_ERROR, error: error }))
  },
  removeRecord: (action$, actionType) => {
    return action$
      .ofType(actionType)
      .switchMap(action => {
        return Observable.fromPromise(firebaseRef(action.recordSetKey + '/' + action.recordKey).remove()).switchMap(
          () => concatPostActions(action)
        )
      })
      .catch(error => Observable.of({ type: APP.LOG_ERROR, error: error }))
  },
  saveRecord: (action$, actionType) => {
    return action$
      .ofType(actionType)
      .switchMap(action => {
        return Observable.fromPromise(
          firebaseRef(action.recordSetKey + '/' + action.recordKey).set(action.recordKeyValue)
        ).switchMap(() => concatPostActions(action))
      })
      .catch(error => Observable.of({ type: APP.LOG_ERROR, error: error }))
  }
}

export default firebaseEpicHelper

Minor Rant: You may notice I avoid concise bodies here and alternatively use explicit return statements. While I'm a huge fan of Chrome, I find debugging concise bodies very hit and miss, though this may be more related to WebPack plugins and sourcemaps. The debugging is especially ugly with all the nesting in RxJS. Firefox Nightly plays much nicer with concise bodies, so I'm a probably too comfortable with Chrome these days.


reducers/firebaseReducerHelper.js

Like our epic helper above, this also provides helper methods for building reducers. A pure function is returned using simple destructuring to return our new state. You'll notice action properties like recordSetKey and recordKey are rolled over to be used in our epics.

Hint: The removeRecordComplete uses a nice destructuring pattern for removing all but one property of an object.)

const firebaseReducerHelper = {
  getRecordSetComplete: (state, action) => {
    return {
      ...state,
      [action.recordSetKey]: action.recordSetKeyValue
    }
  },
  removeRecordComplete: (state, action) => {
    const { [action.recordKey]: filteredValue, ...filteredItems } = state[action.recordSetKey]
    return {
      ...state,
      [action.recordSetKey]: {
        ...filteredItems
      }
    }
  },
  saveRecordComplete: (state, action) => {
    return {
      ...state,
      [action.recordSetKey]: {
        ...state[action.recordSetKey],
        [action.recordKey]: action.recordKeyValue
      }
    }
  }
}

export default firebaseReducerHelper

reducers/todo.js

Using the helper methods above, we can concisely and effortlessly build reducers and epics. Creating an epic for a different recordSet will be identical except for the TODO references.

  • todoEpic - Our epic takes an action stream action$, then uses Observable.merge (a common pattern for combining epics) along with our helper methods for creating our epics. We simply pass action$, along with our action type name, represented through a constant here.
  • todo (reducer) - This is a normal reducer with a switch statement based on the action type, but instead of putting all our logic in each case statement, we simply call the helper method.

Gotcha?: It may seem strange that the TODO.CHANGE_RECORD action type uses saveRecordComplete. This is because, we are using this for a state change and not a persistent change. To make this clearer, we could tweak the naming convention here.

import { Observable } from 'rxjs/Observable'
import 'rxjs/add/observable/merge'

import { TODO } from '../config/constants'
import firebaseEpicHelper from './firebaseEpicHelper'
import firebaseReducerHelper from './firebaseReducerHelper'

export const todoEpic = action$ =>
  Observable.merge(
    firebaseEpicHelper.getRecordSet(action$, TODO.GET_RECORDSET),
    firebaseEpicHelper.removeRecord(action$, TODO.REMOVE_RECORD),
    firebaseEpicHelper.saveRecord(action$, TODO.SAVE_RECORD)
  )

export const todo = (state = { todos: {}, newTodo: { title: '' } }, action) => {
  switch (action.type) {
    case TODO.CHANGE_RECORD:
      return firebaseReducerHelper.saveRecordComplete(state, action)
    case TODO.GET_RECORDSET_COMPLETE:
      return firebaseReducerHelper.getRecordSetComplete(state, action)
    case TODO.REMOVE_RECORD_COMPLETE:
      return firebaseReducerHelper.removeRecordComplete(state, action)
    case TODO.SAVE_RECORD_COMPLETE:
      return firebaseReducerHelper.saveRecordComplete(state, action)
    default:
      return state
  }
}

config/actions.js

Like epics and reducers above, our actions are fairly concise. todoGetRecordSet, todoSaveRecord, and todoRemoveRecord use postActions that will be used by our todo reducer. In the case of todoSaveRecord, we have an additional postAction to clear our Add Todo... text input. We also have some additional logic around created, modified, and ID generation, all of which could be moved to the firebaseEpicHelper.js file.

import { TODO } from '../config/constants'
import uuidv4 from 'uuid/v4'

const todoRecordSetKey = 'todos'
const newTodoRecordSetKey = 'newTodo'
const isAsync = true

export const todoGetRecordSet = () => {
  return {
    type: TODO.GET_RECORDSET,
    recordSetKey: todoRecordSetKey,
    isAsync,
    postActions: [
      {
        type: TODO.GET_RECORDSET_COMPLETE
      }
    ]
  }
}

export const todoChangeNew = recordKeyValue => {
  return {
    type: TODO.CHANGE_RECORD,
    recordSetKey: newTodoRecordSetKey,
    recordKey: 'title',
    recordKeyValue
  }
}

export const todoUpdateRecord = (recordKey, recordKeyValue) => {
  return {
    type: TODO.SAVE_RECORD_COMPLETE,
    recordSetKey: todoRecordSetKey,
    recordKey,
    recordKeyValue
  }
}

export const todoSaveRecord = (recordKey, todo, isNew) => {
  todo = { ...todo, modified: new Date().toUTCString() }
  const postActions = [
    {
      type: TODO.SAVE_RECORD_COMPLETE
    }
  ]
  if (!recordKey) {
    recordKey = uuidv4()
    todo.created = new Date().toUTCString()
    postActions.push({
      type: TODO.CHANGE_RECORD,
      recordSetKey: newTodoRecordSetKey,
      recordKey: 'title',
      recordKeyValue: ''
    })
  }
  return {
    type: TODO.SAVE_RECORD,
    recordSetKey: todoRecordSetKey,
    recordKey,
    recordKeyValue: todo,
    postActions
  }
}

export const todoRemoveRecord = todoKey => {
  return {
    type: TODO.REMOVE_RECORD,
    recordSetKey: todoRecordSetKey,
    recordKey: todoKey,
    postActions: [
      {
        type: TODO.REMOVE_RECORD_COMPLETE
      }
    ]
  }
}

Screenshot

Here is a screenshot of the final app.


References


Important

In order to use with your own Firebase database, update the Firebase constants in config/constants.js. You can find these in Firebase on the Overview tab and then clicking on Add Firebase to your web app. Also, make sure you update your security rules appropriately.


Discuss on Twitter