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.
Top comments (0)