Game Lobby with ReactFire

The following tutorial builds a simple game matchmaking feature from scratch, allowing users to login, join a game lobby, and set their status as “ready” or “not ready”. Our learning goal is to get familiar with React’s new Concurrent Mode features and Firebase’s ReactFire package - both of which are brand new tools in 2020.

In order to follow along, developers should have some prior experience with React, Firebase, and Firestore. You must have a Firebase project with Firestore and Sign in with Google enabled to work with your own live data.

⚠️ Warning: At the time of creation, React Concurrent Mode is marked as experimental and is not suitable for production applications.

Demo of the final game lobby result

Demo of the final game lobby result

Initial Setup

1. Generate a new React application

This can also be seen on the Reactfire quickstart guide. Prerequisite: make sure you have Node.js installed.

npx create-react-app firelobby
cd firelobby

npm start

Install React Experimental Build

npm install react@experimental react-dom@experimental

2. Install ReactFire

Install the Firebase SDK and ReactFire, and optionally add Bulma for better UI styling.

npm install firebase reactfire bulma

3. Enable React Concurrent Mode

Concurrent Mode is a set of new features that help React apps stay responsive and gracefully adjust to the user’s device capabilities and network speed.

You are free to delete everything in the ./src directory except index.js. In order to enable concurrent mode we must modify the way the React application is initialized. Concurrent mode makes use of createRoot from react-dom.

import 'bulma/css/bulma.css';

import React from 'react';
import { createRoot } from 'react-dom';

function App() {
  return <div>Hello World!</div>;
}
createRoot(document.getElementById('root')).render(<App />);

Using createRoot, React will render your Application on the element with the root id in ./public/index.html which was generated with create-react-app.

Firebase Users with React Suspense

4. Create the FirebaseConfigProvider

Reactfire makes strong use of the React Context and Hooks API in order to provide reusable features of the firebase API. FirebaseAppProvider is a React Provider that will provide the firebase config to the component tree.

Create a firebase config in index.js and surround our application with the FirebaseAppProvider component

index.js
// Omitted

import { FirebaseAppProvider } from 'reactfire';

const firebaseConfig = {
  // Retrieved from the firebase console
};

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <div>Hello World!</div>
    </FirebaseAppProvider>
  );
}
// Omitted

Now our component tree within FirebaseAppProvider will have access to the hooks created in ReactFire.

5. Authentication

First, we will create an AuthenticationButtons component that will be responsible for enabling users to sign-in and sign-out of the application.

import firebase from 'firebase/app';

import React from 'react';
import { createRoot } from 'react-dom';
import { AuthCheck, FirebaseAppProvider, useAuth } from 'reactfire';

// Omitted

function AuthenticationButtons() {
  const auth = useAuth();
  const signIn = async () => {
    await auth.signInWithPopup(new firebase.auth.GoogleAuthProvider());
  };
  const signOut = async () => {
    await auth.signOut();
  };
  // When authenticated, show the Sign out button, else Sign in
  return (
    <AuthCheck
      fallback={
        <button className='button is-primary' onClick={signIn}>
          Sign In
        </button>
      }
    >
      <button className='button is-info' onClick={signOut}>
        Sign Out
      </button>
    </AuthCheck>
  );
}
// Omitted

useAuth is a hook provided by ReactFire which, when called within FirebaseAppProvider, will lazy load the Auth feature from Firebase.

AuthCheck is a component that will render its children whenever the user is authenticated, otherwise it will render JSX passed into the fallback prop. This is the same pattern used by the React Concurrent mode feature called Suspense, which will be used momentarily.

Next, we will add our new component to a navbar and add that navbar to the main App function.

// Omitted

function Navbar() {
  return (
    <nav className='navbar'>
      <div className='navbar-brand'>Fire Lobby 🔥</div>
      <div className='navbar-menu'>
        <div className='navbar-start'></div>
        <div className='navbar-end'>
          <div className='navbar-item'>
            <div className='buttons'>
              <AuthenticationButtons />
            </div>
          </div>
        </div>
      </div>
    </nav>
  );
}

// Omitted

Add the navbar to the App.

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <Navbar />
      <div>Hello World!</div>
    </FirebaseAppProvider>
  );
}

// Omitted

Now run the app with npm start. The application should fail to render, throwing this error in the console.

Uncaught Error: AuthenticationButtons suspended while rendering, but no fallback UI was specified.

Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.

6. Suspense

Arguably one of the more exciting features of React’s Concurrent Mode is Suspense.

Disclaimer, there are a lot of nuances and concepts behind Concurrent Mode and Suspense that are not exclusive to React and could be considered advanced topics. I highly recommend this article and the React Suspense presentation linked within it.

ReactFire uses the Suspense API in order to make “contracts” with React to provided an asynchronous friendly experience.

Suspense allows developers to easily specify a “loading” state while waiting on asynchronous tasks. In other words, when we have an action that is considered “blocking”, such as a request for data, we must provide a “non-blocking” version. This non-blocking version is called a “fallback” in Suspense.

The error above tells us that the AuthenticationButtons component was suspended (or “blocked”) while rendering and that a fallback (or “non-blocking” path) was not provided.

We will add Suspense with a fallback to our component tree.

index.js
import React, { Suspense } from 'react';

// Omitted

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <Suspense fallback={<p>Loading...</p>}>
        <Navbar />
        <div>Hello World!</div>
      </Suspense>
    </FirebaseAppProvider>
  );
}

// Omitted

The application should start and allow a user to login and logout.

7. SuspenseWithPerf

ReactFire provides an abstraction of Firebase Performance with the SuspenseWithPerf component.

SuspenseWithPerf starts a Firebase Performance Monitoring trace and ends it when suspense stops suspending.

// Omitted

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <SuspenseWithPerf fallback={<p>Loading...</p>} traceId={'loading-app-status'}>
        <Navbar />
        <div>Hello World!</div>
      </SuspenseWithPerf>
    </FirebaseAppProvider>
  );
}

// Omitted

Firestore Game Lobby

8. Firestore React Hooks

Our next challenge is to create a Firestore collection for the game lobby, then add and remove users.

Create a new function for the lobby and use ReactFire to subscribe to a document reference. It should also wrap our lobby component in an AuthCheck, like we did with the AuthenticationButtons component.

import {
  AuthCheck,
  FirebaseAppProvider,
  SuspenseWithPerf,
  useAuth,
  useFirestore,
  useFirestoreCollectionData
} from 'reactfire';

// Omitted

function Lobby() {
  const lobbyCollection = useFirestore().collection('lobby');
  const lobby = useFirestoreCollectionData(lobbyCollection);

  return (
    <div className='container is-fluid'>
      {lobby.map(m => {
        return (
          <article key={m.email} className='tile is-child notification'>
            <p className='title'>
              {m.displayName} - {m.ready ? 'Ready 🎮' : 'Not Ready ❌'}
            </p>
          </article>
        );
      })}
    </div>
  );
}

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <SuspenseWithPerf fallback={<p>Loading...</p>} traceId={'loading-app-status'}>
        <Navbar />
        <AuthCheck fallback={<p>Not Logged In...</p>}>
          <Lobby></Lobby>
        </AuthCheck>
      </SuspenseWithPerf>
    </FirebaseAppProvider>
  );
}

// Omitted

useFirestore allows us to lazily load the firestore feature from firebase using the config provided by FirebaseAppProvider. This returns firebase.firestore.

useFirestoreCollectionData is a hook that allows components to subscribe to the changes within the firestore collection.

Once we create a subscription to the firestore collection, we map the list of documents to some UI for our component to render dynamically.

9. Update User Status in the Lobby

Now that we have a list of users in the lobby, it’s time to implement action buttons to join and leave the lobby, as well as a function to change the readiness of our user within the lobby. A variable is needed to determine if our user is currently in the lobby, which will determine which buttons we show and hide.

// Omitted

import {
  AuthCheck,
  FirebaseAppProvider,
  SuspenseWithPerf,
  useAuth,
  useFirestore,
  useFirestoreCollectionData,
  useUser
} from 'reactfire';

// Omitted

function Lobby() {
  const { email, displayName, uid } = useUser();
  const lobbyCollection = useFirestore().collection('lobby');
  const lobby = useFirestoreCollectionData(lobbyCollection);

  const userInLobby = lobby.find(m => m.email === email);

  const joinLobby = async () => {
    await lobbyCollection.doc(uid).set({ email, displayName, ready: false });
  };

  const leaveLobby = async () => {
    await lobbyCollection.doc(uid).delete();
  };

  const toggleReadiness = async newReadiness => {
    await lobbyCollection.doc(uid).set({ ready: newReadiness }, { merge: true });
  };

  return (
    <div className='container is-fluid'>
      {lobby.map(m => {
        return (
          <article key={m.email} className='tile is-child notification'>
            <p className='title'>
              {m.displayName} - {m.ready ? 'Ready 🎮' : 'Not Ready ❌'}
            </p>
          </article>
        );
      })}
      <div className='columns'>
        {userInLobby && (
          <div className='column is-1'>
            <button className='button is-primary' onClick={() => toggleReadiness(!userInLobby.ready)}>
              {userInLobby.ready ? 'Not Ready!' : 'Ready!'}
            </button>
          </div>
        )}
        <div className='column is-1'>
          {userInLobby ? (
            <button className='button is-primary' onClick={leaveLobby}>
              Leave
            </button>
          ) : (
            <button className='button is-primary' onClick={joinLobby}>
              Join
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

// Omitted

useUser is the only new ReactFire method used here. It allows the component to subscribe to the authenticated user data in the firebase.auth feature.

It is now possible for a user to join and leave the lobby, as well as mark their status as “ready” or “not ready”, all while reliably handling blocking actions with non-blocking alternatives.

Extra Credit

10. Refactor using the Context API

In this extra step, we will refactor our methods and data for the lobby using the Context API. This is one way to centralize our lobby data and the methods for interacting with the lobby. This refactor would allow us to split our lobby component up without having to pass props down the tree.

First, we create a LobbyContext using createContext and a component to provide that context.

const LobbyContext = React.createContext();

function LobbyProvider(props) {
  const { email, displayName, uid } = useUser();
  const lobbyCollection = useFirestore().collection('lobby');
  const lobby = useFirestoreCollectionData(lobbyCollection);

  const userInLobby = lobby.find(m => m.email === email);

  const joinLobby = async () => {
    await lobbyCollection.doc(uid).set({ email, displayName, ready: false });
  };

  const leaveLobby = async () => {
    await lobbyCollection.doc(uid).delete();
  };

  const toggleReadiness = async newReadiness => {
    await lobbyCollection.doc(uid).set({ ready: newReadiness }, { merge: true });
  };

  return (
    <LobbyContext.Provider value={{ userInLobby, lobby, joinLobby, leaveLobby, toggleReadiness }}>
      {props.children}
    </LobbyContext.Provider>
  );
}

Next, we will use the useContext hook and split our lobby component into two components. Here, I have explored a different pattern for dynamically adding components to the LobbyActions component.

// Omitted

function Lobby() {
  const { lobby } = useContext(LobbyContext);

  return (
    <div className='container is-fluid'>
      {lobby.map(m => {
        return (
          <article key={m.email} className='tile is-child notification'>
            <p className='title'>
              {m.displayName} - {m.ready ? 'Ready 🎮' : 'Not Ready ❌'}
            </p>
          </article>
        );
      })}
    </div>
  );
}

function LobbyActions() {
  const { userInLobby, joinLobby, leaveLobby, toggleReadiness } = useContext(LobbyContext);
  const components = [];

  if (userInLobby) {
    components.push(
      <div key='readyButton' className='column is-1'>
        <button key='readyButton' className='button is-primary' onClick={() => toggleReadiness(!userInLobby.ready)}>
          {userInLobby.ready ? 'Not Ready!' : 'Ready!'}
        </button>
      </div>
    );
    components.push(
      <div key='leaveButton' className='column is-1'>
        <button className='button is-primary' onClick={leaveLobby}>
          Leave
        </button>
      </div>
    );
  } else {
    components.push(
      <div key='joinButton' className='column is-1'>
        <button className='button is-primary' onClick={joinLobby}>
          Join
        </button>
      </div>
    );
  }

  return (
    <div className='container is-fluid'>
      <div className='columns'>{components}</div>
    </div>
  );
}

// Omitted

function App() {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <SuspenseWithPerf fallback={<p>Loading...</p>} traceId={'loading-app-status'}>
        <Navbar />
        <AuthCheck fallback={<p>Not Logged In...</p>}>
          <LobbyProvider>
            <Lobby></Lobby>
            <LobbyActions />
          </LobbyProvider>
        </AuthCheck>
      </SuspenseWithPerf>
    </FirebaseAppProvider>
  );
}

// Omitted

Previously, we used ternaries and expressions to determine what UI to render, but this refactoring creates a component array and conditionally adds JSX to the array - React’s rendering can be very flexible and powerful.

The End!

We have successfully built a small application using ReactFire and React’s experimental concurrent mode. Thanks to the React Hook API, all of the ReactFire hooks will cause our components to re-render whenever there are changes in the observable data from Firebase. There is no need to define logic in our components lifecycle hooks to trigger rendering.

I highly recommend checking our ReactFire’s API reference and exploring more ways to complete the above goals.

Questions? Let's chat

Open Discord