Sep 24, 2019

How we use Firebase instead of Redux (with React)

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

Note: we have a new, hook-based approach that we use instead of this now. Learn more here.

Background

Vivek and I use Firebase with React to operate Pesto.

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": {
"Pesto": { ... },
...
},
"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:

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)

You can view the source code (MIT license, feel free to use it) on Github here.

Note: we have a new, hook-based approach that we use instead of this now. Learn more here.