Firebase listener with React Hooks

Mel picture Mel · Jan 30, 2020 · Viewed 9k times · Source

I am trying to figure out how to use a Firebase listener so that cloud firestore data is refreshed with react hooks updates.

Initially, I made this using a class component with a componentDidMount function to get the firestore data.

this.props.firebase.db
    .collection('users')
    // .doc(this.props.firebase.db.collection('users').doc(this.props.firebase.authUser.uid))
.doc(this.props.firebase.db.collection('users').doc(this.props.authUser.uid))
.get()
.then(doc => {
    this.setState({ name: doc.data().name });
    // loading: false,
  });  
}

That breaks when the page updates, so I am trying to figure out how to move the listener to react hooks.

I have installed the react-firebase-hooks tool - although I can't figure out how to read the instructions to be able to get it to work.

I have a function component as follows:

import React, { useState, useEffect } from 'react';
import { useDocument } from 'react-firebase-hooks/firestore';

import {
    BrowserRouter as Router,
    Route,
    Link,
    Switch,
    useRouteMatch,
 } from 'react-router-dom';
import * as ROUTES from '../../constants/Routes';
import { compose } from 'recompose';
import { withFirebase } from '../Firebase/Index';
import { AuthUserContext, withAuthorization, withEmailVerification, withAuthentication } from '../Session/Index';

function Dashboard2(authUser) {
    const FirestoreDocument = () => {

        const [value, loading, error] = useDocument(
          Firebase.db.doc(authUser.uid),
          //firebase.db.doc(authUser.uid),
          //firebase.firestore.doc(authUser.uid),
          {
            snapshotListenOptions: { includeMetadataChanges: true },
          }
        );
    return (

        <div>    



                <p>
                    {error && <strong>Error: {JSON.stringify(error)}</strong>}
                    {loading && <span>Document: Loading...</span>}
                    {value && <span>Document: {JSON.stringify(value.data())}</span>}
                </p>




        </div>

    );
  }
}

export default withAuthentication(Dashboard2);

This component is wrapped in an authUser wrapper at the route level as follows:

<Route path={ROUTES.DASHBOARD2} render={props => (
          <AuthUserContext.Consumer>
             { authUser => ( 
                <Dashboard2 authUser={authUser} {...props} />  
             )}
          </AuthUserContext.Consumer>
        )} />

I have a firebase.js file, which plugs into firestore as follows:

class Firebase {
  constructor() {
    app.initializeApp(config).firestore();
    /* helpers */
    this.fieldValue = app.firestore.FieldValue;


    /* Firebase APIs */
    this.auth = app.auth();
    this.db = app.firestore();


  }

It also defines a listener to know when the authUser changes:

onAuthUserListener(next, fallback) {
    // onUserDataListener(next, fallback) {
      return this.auth.onAuthStateChanged(authUser => {
        if (authUser) {
          this.user(authUser.uid)
            .get()
            .then(snapshot => {
            let snapshotData = snapshot.data();

            let userData = {
              ...snapshotData, // snapshotData first so it doesn't override information from authUser object
              uid: authUser.uid,
              email: authUser.email,
              emailVerified: authUser.emailVerifed,
              providerData: authUser.providerData
            };

            setTimeout(() => next(userData), 0); // escapes this Promise's error handler
          })

          .catch(err => {
            // TODO: Handle error?
            console.error('An error occured -> ', err.code ? err.code + ': ' + err.message : (err.message || err));
            setTimeout(fallback, 0); // escapes this Promise's error handler
          });

        };
        if (!authUser) {
          // user not logged in, call fallback handler
          fallback();
          return;
        }
    });
  };

Then, in my firebase context setup I have:

import FirebaseContext, { withFirebase } from './Context';
import Firebase from '../../firebase';
export default Firebase;
export { FirebaseContext, withFirebase };

The context is setup in a withFirebase wrapper as follows:

import React from 'react';
const FirebaseContext = React.createContext(null);

export const withFirebase = Component => props => (
  <FirebaseContext.Consumer>
    {firebase => <Component {...props} firebase={firebase} />}
  </FirebaseContext.Consumer>
);
export default FirebaseContext;

Then, in my withAuthentication HOC, I have a context provider as:

import React from 'react';
import { AuthUserContext } from '../Session/Index';
import { withFirebase } from '../Firebase/Index';

const withAuthentication = Component => {
  class WithAuthentication extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        authUser: null,
      };  
    }

    componentDidMount() {
      this.listener = this.props.firebase.auth.onAuthStateChanged(
        authUser => {
           authUser
            ? this.setState({ authUser })
            : this.setState({ authUser: null });
        },
      );
    }

    componentWillUnmount() {
      this.listener();
    };  

    render() {
      return (
        <AuthUserContext.Provider value={this.state.authUser}>
          <Component {...this.props} />
        </AuthUserContext.Provider>
      );
    }
  }
  return withFirebase(WithAuthentication);

};
export default withAuthentication;

Currently - when I try this, I get an error in the Dashboard2 component that says:

Firebase' is not defined

I tried lowercase firebase and get the same error.

I also tried firebase.firestore and Firebase.firestore. I get the same error.

I'm wondering if I can't use my HOC with a function component?

I have seen this demo app and this blog post.

Following the advice in the blog, I made a new firebase/contextReader.jsx with:

 import React, { useEffect, useContext } from 'react';
import Firebase from '../../firebase';



export const userContext = React.createContext({
    user: null,
  })

export const useSession = () => {
    const { user } = useContext(userContext)
    return user
  }

  export const useAuth = () => {
    const [state, setState] = React.useState(() => 
        { const user = firebase.auth().currentUser 
            return { initializing: !user, user, } 
        }
    );
    function onChange(user) {
      setState({ initializing: false, user })
    }

    React.useEffect(() => {
      // listen for auth state changes
      const unsubscribe = firebase.auth().onAuthStateChanged(onChange)
      // unsubscribe to the listener when unmounting
      return () => unsubscribe()
    }, [])

    return state
  }  

Then I try to wrap my App.jsx in that reader with:

function App() {
  const { initializing, user } = useAuth()
  if (initializing) {
    return <div>Loading</div>
  }

    // )
// }
// const App = () => (
  return (
    <userContext.Provider value={{ user }}> 


    <Router>
        <Navigation />
        <Route path={ROUTES.LANDING} exact component={StandardLanding} />

When I try this, I get an error that says:

TypeError: _firebase__WEBPACK_IMPORTED_MODULE_2__.default.auth is not a function

I have seen this post dealing with that error and have tried uninstalling and reinstalling yarn. It makes no difference.

When I look at the demo app, it suggests that context should be created using an 'interface' method. I can't see where this is coming from - I can't find a reference to explain it in the documentation.

I can't make sense of the instructions other than to try what I have done to plug this in.

I have seen this post which attempts to listen to firestore without using react-firebase-hooks. The answers point back to trying to figure out how to use this tool.

I have read this excellent explanation which goes into how to move away from HOCs to hooks. I'm stuck with how to integrate the firebase listener.

I have seen this post which provides a helpful example for how to think about doing this. Not sure if I should be trying to do this in the authListener componentDidMount - or in the Dashboard component that is trying to use it.

NEXT ATTEMPT I found this post, which is trying to solve the same problem.

When I try to implement the solution offered by Shubham Khatri, I set up the firebase config as follows:

A context provider with: import React, {useContext} from 'react'; import Firebase from '../../firebase';

const FirebaseContext = React.createContext(); 

export const FirebaseProvider = (props) => ( 
   <FirebaseContext.Provider value={new Firebase()}> 
      {props.children} 
   </FirebaseContext.Provider> 
); 

The context hook then has:

import React, { useEffect, useContext, useState } from 'react';

const useFirebaseAuthentication = (firebase) => {
    const [authUser, setAuthUser] = useState(null);

    useEffect(() =>{
       const unlisten = 
firebase.auth.onAuthStateChanged(
          authUser => {
            authUser
              ? setAuthUser(authUser)
              : setAuthUser(null);
          },
       );
       return () => {
           unlisten();
       }
    });

    return authUser
}

export default useFirebaseAuthentication;

Then in the index.js I wrap the App in the provider as:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App/Index';
import {FirebaseProvider} from './components/Firebase/ContextHookProvider';

import * as serviceWorker from './serviceWorker';


ReactDOM.render(

    <FirebaseProvider> 
    <App /> 
    </FirebaseProvider>,
    document.getElementById('root')
);

    serviceWorker.unregister();

Then, when I try to use the listener in the component I have:

import React, {useContext} from 'react';
import { FirebaseContext } from '../Firebase/ContextHookProvider';
import useFirebaseAuthentication from '../Firebase/ContextHook';


const Dashboard2 = (props) => {
    const firebase = useContext(FirebaseContext);
    const authUser = 
useFirebaseAuthentication(firebase);

    return (
        <div>authUser.email</div>
    )
 }

 export default Dashboard2;

And I try to use it as a route with no components or auth wrapper:

<Route path={ROUTES.DASHBOARD2} component={Dashboard2} />

When I try this, I get an error that says:

Attempted import error: 'FirebaseContext' is not exported from '../Firebase/ContextHookProvider'.

That error message makes sense, because ContextHookProvider does not export FirebaseContext - it exports FirebaseProvider - but if I don't try to import this in Dashboard2 - then I can't access it in the function that tries to use it.

One side effect of this attempt is that my sign up method no longer works. It now generates an error message that says:

TypeError: Cannot read property 'doCreateUserWithEmailAndPassword' of null

I'll solve this problem later- but there must be a way to figure out how to use react with firebase that does not involve months of this loop through millions of avenues that don't work to get a basic auth setup. Is there a starter kit for firebase (firestore) that works with react hooks?

Next attempt I tried to follow the approach in this udemy course- but it only works to generate a form input - there isn't a listener to put around the routes to adjust with the authenticated user.

I tried to follow the approach in this youtube tutorial - which has this repo to work from. It shows how to use hooks, but not how to use context.

NEXT ATTEMPT I found this repo that seems to have a well thought out approach to using hooks with firestore. However, I can't make sense of the code.

I cloned this - and tried to add all the public files and then when I run it - I can't actually get the code to operate. I'm not sure what's missing from the instructions for how to get this to run in order to see if there are lessons in the code that can help solve this problem.

NEXT ATTEMPT

I bought the divjoy template, which is advertised as being setup for firebase (it isn't setup for firestore in case anyone else is considering this as an option).

That template proposes an auth wrapper that initialises the config of the app - but just for the auth methods - so it needs to be restructured to allow another context provider for firestore. When you muddle through that process and use the process shown in this post, what's left is an error in the following callback:

useEffect(() => {
    const unsubscribe = firebase.auth().onAuthStateChanged(user => {
      if (user) {
        setUser(user);
      } else {
        setUser(false);
      }
    });

It doesn't know what firebase is. That's because it's defined in the firebase context provider which is imported and defined (in the useProvideAuth() function) as:

  const firebase = useContext(FirebaseContext)

Without chances to the callback, the error says:

React Hook useEffect has a missing dependency: 'firebase'. Either include it or remove the dependency array

Or, if I try and add that const to the callback, I get an error that says:

React Hook "useContext" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function

NEXT ATTEMPT

I have reduced my firebase config file down to just config variables (I will write helpers in the context providers for each context I want to use).

import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';

const devConfig = {
    apiKey: process.env.REACT_APP_DEV_API_KEY,
    authDomain: process.env.REACT_APP_DEV_AUTH_DOMAIN,
    databaseURL: process.env.REACT_APP_DEV_DATABASE_URL,
    projectId: process.env.REACT_APP_DEV_PROJECT_ID,
    storageBucket: process.env.REACT_APP_DEV_STORAGE_BUCKET,
    messagingSenderId: process.env.REACT_APP_DEV_MESSAGING_SENDER_ID,
    appId: process.env.REACT_APP_DEV_APP_ID

  };


  const prodConfig = {
    apiKey: process.env.REACT_APP_PROD_API_KEY,
    authDomain: process.env.REACT_APP_PROD_AUTH_DOMAIN,
    databaseURL: process.env.REACT_APP_PROD_DATABASE_URL,
    projectId: process.env.REACT_APP_PROD_PROJECT_ID,
    storageBucket: process.env.REACT_APP_PROD_STORAGE_BUCKET,
    messagingSenderId: 
process.env.REACT_APP_PROD_MESSAGING_SENDER_ID,
    appId: process.env.REACT_APP_PROD_APP_ID
  };

  const config =
    process.env.NODE_ENV === 'production' ? prodConfig : devConfig;


class Firebase {
  constructor() {
    firebase.initializeApp(config);
    this.firebase = firebase;
    this.firestore = firebase.firestore();
    this.auth = firebase.auth();
  }
};

export default Firebase;  

I then have an auth context provider as follows:

import React, { useState, useEffect, useContext, createContext } from "react";
import Firebase from "../firebase";

const authContext = createContext();

// Provider component that wraps app and makes auth object ...
// ... available to any child component that calls useAuth().
export function ProvideAuth({ children }) {
  const auth = useProvideAuth();

  return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

// Hook for child components to get the auth object ...
// ... and update when it changes.
export const useAuth = () => {

  return useContext(authContext);
};

// Provider hook that creates auth object and handles state
function useProvideAuth() {
  const [user, setUser] = useState(null);


  const signup = (email, password) => {
    return Firebase
      .auth()
      .createUserWithEmailAndPassword(email, password)
      .then(response => {
        setUser(response.user);
        return response.user;
      });
  };

  const signin = (email, password) => {
    return Firebase
      .auth()
      .signInWithEmailAndPassword(email, password)
      .then(response => {
        setUser(response.user);
        return response.user;
      });
  };



  const signout = () => {
    return Firebase
      .auth()
      .signOut()
      .then(() => {
        setUser(false);
      });
  };

  const sendPasswordResetEmail = email => {
    return Firebase
      .auth()
      .sendPasswordResetEmail(email)
      .then(() => {
        return true;
      });
  };

  const confirmPasswordReset = (password, code) => {
    // Get code from query string object
    const resetCode = code || getFromQueryString("oobCode");

    return Firebase
      .auth()
      .confirmPasswordReset(resetCode, password)
      .then(() => {
        return true;
      });
  };

  // Subscribe to user on mount
  useEffect(() => {

    const unsubscribe = firebase.auth().onAuthStateChanged(user => {
      if (user) {
        setUser(user);
      } else {
        setUser(false);
      }
    });

    // Subscription unsubscribe function
    return () => unsubscribe();
  }, []);

  return {
    user,
    signup,
    signin,
    signout,
    sendPasswordResetEmail,
    confirmPasswordReset
  };
}

const getFromQueryString = key => {
  return queryString.parse(window.location.search)[key];
};

I also made a firebase context provider as follows:

import React, { createContext } from 'react';
import Firebase from "../../firebase";

const FirebaseContext = createContext(null)
export { FirebaseContext }


export default ({ children }) => {

    return (
      <FirebaseContext.Provider value={ Firebase }>
        { children }
      </FirebaseContext.Provider>
    )
  }

Then, in index.js I wrap the app in the firebase provider

ReactDom.render(
    <FirebaseProvider>
        <App />
    </FirebaseProvider>, 
document.getElementById("root"));

serviceWorker.unregister();

and in my routes list, I have wrapped the relevant routes in the auth provider:

import React from "react";
import IndexPage from "./index";
import { Switch, Route, Router } from "./../util/router.js";

import { ProvideAuth } from "./../util/auth.js";

function App(props) {
  return (
    <ProvideAuth>
      <Router>
        <Switch>
          <Route exact path="/" component={IndexPage} />

          <Route
            component={({ location }) => {
              return (
                <div
                  style={{
                    padding: "50px",
                    width: "100%",
                    textAlign: "center"
                  }}
                >
                  The page <code>{location.pathname}</code> could not be found.
                </div>
              );
            }}
          />
        </Switch>
      </Router>
    </ProvideAuth>
  );
}

export default App;

On this particular attempt, I'm back to the problem flagged earlier with this error:

TypeError: _firebase__WEBPACK_IMPORTED_MODULE_2__.default.auth is not a function

It points to this line of the auth provider as creating the problem:

useEffect(() => {

    const unsubscribe = firebase.auth().onAuthStateChanged(user => {
      if (user) {
        setUser(user);
      } else {
        setUser(false);
      }
    });

I have tried using capitalised F in Firebase and it generates the same error.

When I try Tristan's advice, I remove all of those things and try and define my unsubscribe method as an unlisten method (I don't know why he isn't using the firebase language - but if his approach worked, I'd try harder to figure out why). When I try to use his solution, the error message says:

TypeError: _util_contexts_Firebase__WEBPACK_IMPORTED_MODULE_8___default(...) is not a function

The answer to this post suggests removing () from after auth. When I try that, I get an error that says:

TypeError: Cannot read property 'onAuthStateChanged' of undefined

However this post suggest a problem with the way firebase is imported in the auth file.

I have it imported as: import Firebase from "../firebase";

Firebase is the name of the class.

The videos Tristan recommended are helpful background, but I'm currently on episode 9 and still not found the part that is supposed to help solve this problem. Does anyone know where to find that?

NEXT ATTEMPT Next - and trying to solve the context problem only - I have imported both createContext and useContext and tried to use them as shown in this documentation.

I can't get passed an error that says:

Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: ...

I have been through the suggestions in this link to try and solve this problem and cannot figure it out. I don't have any of the problems shown in this trouble shooting guide.

Currently - the context statement looks as follows:

import React, {  useContext } from 'react';
import Firebase from "../../firebase";


  export const FirebaseContext = React.createContext();

  export const useFirebase = useContext(FirebaseContext);

  export const FirebaseProvider = props => (
    <FirebaseContext.Provider value={new Firebase()}>
      {props.children}
    </FirebaseContext.Provider>
  );  

I spent time using this udemy course to try and figure out the context and hooks element to this problem - after watching it - the only aspect to the solution proposed by Tristan below is that the createContext method isn't called correctly in his post. it needs to be "React.createContext" but it still doesn't get anywhere close to solving the problem.

I'm still stuck.

Can anyone see what's gone awry here?

Answer

Tristan Trainer picture Tristan Trainer · Feb 2, 2020

Major Edit: Took some time to look into this a bit more this is what I have come up with is a cleaner solution, someone might disagree with me about this being a good way to approach this.

UseFirebase Auth Hook

import { useEffect, useState, useCallback } from 'react';
import firebase from 'firebase/app';
import 'firebase/auth';

const firebaseConfig = {
  apiKey: "xxxxxxxxxxxxxx",
  authDomain: "xxxx.firebaseapp.com",
  databaseURL: "https://xxxx.firebaseio.com",
  projectId: "xxxx",
  storageBucket: "xxxx.appspot.com",
  messagingSenderId: "xxxxxxxx",
  appId: "1:xxxxxxxxxx:web:xxxxxxxxx"
};

firebase.initializeApp(firebaseConfig)

const useFirebase = () => {
  const [authUser, setAuthUser] = useState(firebase.auth().currentUser);

  useEffect(() => {
    const unsubscribe = firebase.auth()
      .onAuthStateChanged((user) => setAuthUser(user))
    return () => {
      unsubscribe()
    };
  }, []);

  const login = useCallback((email, password) => firebase.auth()
    .signInWithEmailAndPassword(email, password), []);

  const logout = useCallback(() => firebase.auth().signOut(), [])

  return { login, authUser, logout }
}

export { useFirebase }

If authUser is null then not authenticated, if user has a value, then authenticated.

firebaseConfig can be found on the firebase Console => Project Settings => Apps => Config Radio Button

useEffect(() => {
  const unsubscribe = firebase.auth()
    .onAuthStateChanged(setAuthUser)

  return () => {
    unsubscribe()
  };
}, []);

This useEffect hook is the core to tracking the authChanges of a user. We add a listener to the onAuthStateChanged event of firebase.auth() that updates the value of authUser. This method returns a callback for unsubscribing this listener which we can use to clean up the listener when the useFirebase hook is refreshed.

This is the only hook we need for firebase authentication (other hooks can be made for firestore etc.

const App = () => {
  const { login, authUser, logout } = useFirebase();

  if (authUser) {
    return <div>
      <label>User is Authenticated</label>
      <button onClick={logout}>Logout</button>
    </div>
  }

  const handleLogin = () => {
    login("[email protected]", "password0");
  }

  return <div>
    <label>User is not Authenticated</label>
    <button onClick={handleLogin}>Log In</button>
  </div>
}

This is a basic implementation of the App component of a create-react-app

useFirestore Database Hook

const useFirestore = () => {
  const getDocument = (documentPath, onUpdate) => {
    firebase.firestore()
      .doc(documentPath)
      .onSnapshot(onUpdate);
  }

  const saveDocument = (documentPath, document) => {
    firebase.firestore()
      .doc(documentPath)
      .set(document);
  }

  const getCollection = (collectionPath, onUpdate) => {
    firebase.firestore()
      .collection(collectionPath)
      .onSnapshot(onUpdate);
  }

  const saveCollection = (collectionPath, collection) => {
    firebase.firestore()
      .collection(collectionPath)
      .set(collection)
  }

  return { getDocument, saveDocument, getCollection, saveCollection }
}

This can be implemented in your component like so:

const firestore = useFirestore();
const [document, setDocument] = useState();

const handleGet = () => {
  firestore.getDocument(
    "Test/ItsWFgksrBvsDbx1ttTf", 
    (result) => setDocument(result.data())
  );
}

const handleSave = () => {
  firestore.saveDocument(
    "Test/ItsWFgksrBvsDbx1ttTf", 
    { ...document, newField: "Hi there" }
  );

}

This then removes the need for the React useContext as we get updates directly from firebase itself.

Notice a couple of things:

  1. Saving an unchanged document does not trigger a new snapshot so "oversaving" doesn't cause rerenders
  2. On calling getDocument the callback onUpdate is called straight away with an initial "snapshot" so you don't need extra code for getting the initial state of the document.

Edit has removed a large chunk of the old answer