How we use Firebase instead of Redux (with React)

How we use Firebase instead of Redux (with React)

This article explains how Pragli uses Firebase Realtime Database like a Redux store for our React front-end.

Background

Vivek and I use Firebase with React to operate Pragli.

For those who aren't familiar, Firebase Realtime Database (RTDB) provides in-browser (or in-app) data reading, writing, and subscribing. One client can simply write to a JSON document, and the document immediately propagates to all other clients. This largely eliminates the need for server code.

Data is represented as one large JSON document with subdata referenced by "routes." For instance, my user in the JSON document below is at the route users/dsafreno.

{
  "teams": {
    "Pragli": { ... },
    ...
  },
  "users": {
    "dsafreno": { ... },
    "vnair611": { ... },
    ...
  }
}

For a production application, the client can't do everything, largely for security reasons. For instance, sending emails or authenticating with integrations requires tokens that should not be shared with the client. We fill in the gaps using Firebase's Cloud Functions.

Wiring Firebase RTDB and React Sucks (By Default)

The problem with Firebase RTDB is that it isn't designed for React, so wiring the two together sucks. We ended up doing the same thing over and over again:

  • subscribe to a bunch of data in componentDidMount
  • unsubscribe in componentWillUnmount
  • perform our "data mounted" logic in componentDidUpdate
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: null, team: null };
  }
  componentDidMount() {
    let {userId, teamId} = this.props;
    // subscribe to user data
    let userRef = firebase.database().ref(`users/${userId}`);
    let userOff = userRef.on('value', (snap) => {
      this.setState({user: snap.val()});
    }
    this.userOff = () => ref.off('value', userOff);
    // subscribe to team data
    let teamRef = firebase.database().ref(`teams/${teamId}`);
    let teamOff = teamRef.on('value', (snap) => {
      this.setState({team: snap.val()});
    }
    this.teamOff = () => ref.off('value', teamOff);
  }
  componentDidUpdate(prevProps, prevState) {
    if (!prevState.user && this.state.user) {
        // first time we got user data!
    }
    if (!prevState.team && this.state.team) {
        // first time we got team data!
    }
  }
  componentWillUnmount() {
    this.userOff();
    this.teamOff();
  }
  render() {
    let { user, team } = this.state;
    if (!user || !team) {
      return null;
    }
    // ...
  }
}

export default Example

Ugly, right? That's a ton of boilerplate for a React component to subscribe to the data at two routes in Firebase. Components that required more data were even worse.

So we brainstormed how we could do better, considering a few solutions.

Ideas

Pass more data as props from higher-level components

We considered subscribing to data in a high level component and passing it down to child components.We started implementing this in some places, but we ultimately got frustrated because it caused too many child / intermediary component re-renders, slowing down the application.

Load Data from Firebase RTDB => Redux => React

Redux is a state container for JS apps commonly used alongside React.

We considered syncing our data into Redux from Firebase RTDB and then subscribing to the Redux store for data. There's even a library for making React, Redux, and Firebase RTDB play nicely together.

But isn't the whole point of Firebase RTDB to have one easy-to-use source of state? Why duplicate with Redux?

We decided we wanted to come up with a solution that didn't involve piping state through Redux.

Which led us to our final solution...

Autoload Data with Specs

Ultimately, we decided to write our own wrapper function to make accessing Firebase RTDB more convenient.

The key idea is to statically specify which data your component needs via a static template. Once the data becomes available, Firebase RTDB fetches that data and passes it directly into the component as props.

We use the following schema:

const MY_DATA_SPEC = {
  name: 'myData',
  template: 'data/{myUid}',
  await: true
};

This schema specifies that the data at route data/{myUid} is passed into the component as the myData prop (myUid is assumed to be passed in as a prop from the parent).

The await: true prevents the component from mounting until it has received some data at that path (so that componentDidMount always has data).

Wiring it together - withDbData

We wrote withDbData to conveniently load components with the data in this spec.

Here's what the above component looks like now:

class Example extends React.Component {
  componentDidMount() {
    // first time we got data!
  }
  render() {
    let {user, team} = this.props;
    // don't need to null check since we await the data!
  }
}

const USER_SPEC = {
  name: 'user',
  template: 'users/{userId}',
  await: true
};

const TEAM_SPEC = {
  name: 'team',
  template: 'teams/{teamId}',
  await: true
};

export default withDbData([USER_SPEC, TEAM_SPEC])(Example)

Here's the source code (MIT license, feel free to use it). It's also available on Github here.

import React from 'react';
import firebase from 'firebase/app';
import equal from 'deep-equal';

export function withDbData(specs) {
  let propToSpecs = {};
  for (let spec of specs) {
    let {propIds} = parseSpec(spec);
    for (let propId of propIds) {
      if (!propToSpecs[propId]) {
        propToSpecs[propId] = [];
      }
      propToSpecs[propId].push(spec);
    }
  }

  return (Child) => {
    let Wrapper = class extends React.PureComponent {
      constructor(props) {
        super(props);
        this.unmounting = false;
        this.offs = {};
        this.state = {};
      }
      subscribeToSpec(spec) {
        let { name, keys } = spec;
        let { propIds, formatPath } = parseSpec(spec);
        let path = formatPath(this.props);
        if (!path) {
          return;
        }
        let ref = firebase.database().ref(path);
        let offFunc = ref.on('value', (snap) => {
          let dat = keys ? filterKeys(snap.val(), keys) : snap.val();
          if (equal(dat, this.state[name])) {
            return;
          }
          this.setState({
            [name]: dat,
          });
        });
        let hasBeenOffed = false;
        let off = () => {
          if (hasBeenOffed) {
            return;
          }
          hasBeenOffed = true;
          if (!this.unmounting) {
            this.setState({
              [name]: null,
            });
          }
          ref.off('value', offFunc);
        };
        for (let propId of propIds) {
          if (!this.offs[propId]) {
            this.offs[propId] = [];
          }
          this.offs[propId].push(off)
        }
      }
      componentDidMount() {
        for (let spec of specs) {
          this.subscribeToSpec(spec)
        }
      }
      componentDidUpdate(prevProps) {
        let resubs = new Set();
        for (let prop of Object.keys(propToSpecs)) {
          if (prevProps[prop] !== this.props[prop]) {
            if (this.offs[prop]) {
              for (let off of this.offs[prop]) {
                off();
              }
            }
            this.offs[prop] = [];
            for (let spec of propToSpecs[prop]) {
              if (resubs.has(spec.name)) {
                continue;
              }
              resubs.add(spec.name);
              this.subscribeToSpec(spec);
            }
          }
        }
      }
      componentWillUnmount() {
        this.unmounting = true;
        for (let offList of Object.values(this.offs)) {
          for (let off of offList) {
            off();
          }
        }
        this.offs = {};
      }
      render() {
        for (let spec of specs) {
          if (spec.await && !this.state[spec.name]) {
            return null;
          }
        }
        let childProps = Object.assign({}, this.props, this.state);
        return (<Child {... childProps} />);
      }
    }
    return Wrapper;
  }
}

Conclusion

Did this help you learn how to better use Firebase with React? Do you have any follow up questions? Shoot me an email at doug@pragli.com, or follow up with me on Twitter @dougsafreno.

Show Comments