DEV Community

Cover image for FERNtastic Web Development: A Starter's Walkthrough of the FERN Stack
Nathaniel Arfin
Nathaniel Arfin

Posted on • Updated on

FERNtastic Web Development: A Starter's Walkthrough of the FERN Stack

Today I’m going to walk you through my set up getting started with the FERN stack - that stands for Firebase, Express, React, Node.js. It’s a variant on the incredibly popular MERN stack. But you’ll see that by swapping out MongoDB for Firebase RTDB, we’re doing a bit more than swapping out our database provider.

For the React app, we’re going to use Vite, React Router, React Query, and Material UI. These are my preferences, by no means are they necessary! The FERN stack is flexible. We’re going serve a static React app in this walkthrough, but this stack sets you up for SSR success just as well!

For those of you who are unaware, Firebase grew from a Chat API-as-a-Service company called Envolve. The Founders quickly realized that developers were taking advantage of the powerful real-time architecture they had built to power games, platforms, and other real-time services. This spun out into Firebase, they were acquired by Google in 2014, and now offers a whole suite of products to help developers ship quality products quickly.

Firebase has a whole range of features, but in this walk-through, we’re specifically going to be focusing on Firebase Realtime Database and Firebase Authentication.

This walkthrough assumes a baseline knowledge of Node.js and React.

Getting Started

Hop into your favourite IDE and let’s get set up! Start by making and moving into your project directory, and get the project set up.

mkdir fern-stack-walkthrough && cd fern-stack-walkthrough

npm init -y

Now we’ll install express and a few dependencies we’re going to need to get started, and then make our index file.

npm install express cors dotenv path url nodemon firebase firebase-admin

touch index.js

I personally like to use ESModules (or ECMAScript Modules). You can still with Common JS if you prefer, but I’m going to change my package.json to include “’type’:’modules’”, and I’m also going to want live refreshes, so we’ll put in our start script. It should look something like this:

//package.json
{
  "name": "fern-stack-walkthrough",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
        "start": "nodemon .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "path": "^0.12.7",
    "url": "^0.11.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that our environment is all set up, we can get coding. Head over to index.js, and import the dependancies. Take note of how we import dotenv.

//index.js
import express, {json} from 'express';
import * as dotenv from 'dotenv'
import cors from 'cors';
Enter fullscreen mode Exit fullscreen mode

Now, use those dependancies to start setting up your server

//index.js
//...imports 

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(json());

app.get('/', (req, res) => res.send(\`Hello World!\\`));

app.listen(port, () => console.log(\`Express app listening on ${port}\\`))
Enter fullscreen mode Exit fullscreen mode

Alright, let’s break down what we’ve done here:

  • First, we initialize dotenv. This allows us to process variables passed in from the .env file. We don’t have anything in there yet, but we will soon!
  • Defining what we’re calling our server (in this case ‘app’)
  • Defining the PORT that we’re running on. First we check to see if it’s defined in the environment, or we default to 3000.
  • Telling our app use the Cross Origin Resource Sharing middleware on all routes. In production, you’ll want to be more restrictive with your usage, but in development we’re going to allow all requests.
  • Using the express.json middleware on all routes. This allows us to retrieve the req.body object from any HTTP request with the Content-Type: application/json header.
  • Defined our ‘/’ route to return “Hello World!”
  • Start the app on port 3000 and listen for HTTP requests!

Fire it up with a quick npm run start and you should see something a little like this:

> fern-stack-walkthrough@1.0.0 start
> nodemon .

[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .`
Express app listening on 3000
Enter fullscreen mode Exit fullscreen mode

Congratulations! Your server is up and running. You can now navigate to http://localhost:3000/ and you should see something that looks like this:

Browser displaying "Hello World"

Front-end setup

Now that we have our server up and running, let’s start building our front-end! Open a new terminal window in your IDE, and let’s get started with our client!

npm create vite@latest client -- --template react

cd client

Now, before we fire it up, let’s install our dependancies:

npm install @mui/material @emotion/react @emotion/styled @mui/icons-material react-router-dom react-query firebase

Now that the dependancies are all installed, head over to the vite.config.js, and we will proxy our API requests to the backend. It should look a little something like this:

// vite.config.js
import react from '@vitejs/plugin-react';

export default {
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

This is our config file for our vite server. Here, we’re telling vite to proxy any requests made to ‘/api*’ should be sent through to our express app at Port 3000 (make sure you define the correct port).

Now by default, the Vite template has a lot in it. We can safely get rid of the App.css file, anything in the public folder, anything in the assets folder, as well as the majority of the index.css and App.jsx files. At this point, your entire directory should look like:

- index.js
- package.json
- package-lock.json
- node-modules/...
- client/
  - index.html
  - vite.config.js 
  - public/
  - node-modules/...
  - src/
    - assets/
    - App.jsx
    - index.css
    - main.jsx
Enter fullscreen mode Exit fullscreen mode
// client/src/App.jsx
import { useState } from 'react'

function App() {

  return (
    <div className="App">
      <h1>FERN</h1>
    </div>
  )
}

export default App

Enter fullscreen mode Exit fullscreen mode
// client/src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode
/* client/src/index.css */
body {
  margin: 0;
  display: flex;
  min-width: 320px;
  min-height: 100vh;
}
Enter fullscreen mode Exit fullscreen mode

And with that, your website should now look like…this:

A Browser displaying "FERN"

Pages and Routing

We’ll work on that soon. For now, we have the App running, and we can start getting our pages together. For now, we’re going to build some very basic pages. We’ll revisit them once we’ve set up a but more code. Create a new folder /src/pages and create the following very basic pages.

//Home.jsx

const Home = () => {
    return(<h1>Home</h1>)
}
export default Home;
Enter fullscreen mode Exit fullscreen mode
//About.jsx

const About = () => {
    return(<h1>About</h1>)
};
export default About;
Enter fullscreen mode Exit fullscreen mode
//Login.jsx

const Login = () => {
    return(<h1>Login</h1>)
};
export default Login;
Enter fullscreen mode Exit fullscreen mode

Now that we have our pages, we have to render them! Head back to your App.jsx, and we’ll set it up to import our pages, and display them to the user!

//App.jsx

import React, { useState, useEffect, Suspense } from 'react';
import {BrowserRouter as Router, Route, Routes} from 'react-router-dom';

const App = () => {
  const [routes, setRoutes] = useState([]);

  useEffect(() => {
    function loadPages() {
      const context = import.meta.globEager('./pages/*.jsx');
      const routeList = [];
      for (const path in context) {
        const module = context[path];
        const pageName = path.replace('./pages/', '').replace('.jsx', '');
        const routePath = pageName === 'Home' ? '/' : \`/${pageName.toLowerCase()}\\`;
        const route = {
          path: routePath,
          component: module.default,
        };
        routeList.push(route);
      }
      setRoutes(routeList);
    }
    loadPages();
  }, []);
  return (
    <Router>
        <Suspense fallback={<div>Loading...</div>}>
          <Routes>
            {routes.map((route, index) => (
              <Route
                key={index}
                path={route.path}
                element={<route.component />}
              />
            ))}
          </Routes>
        </Suspense>
    </Router>
  );
};
export default App;
Enter fullscreen mode Exit fullscreen mode

Ok WOAH. That’s a lot of changes. Let’s go through how we went from our simple, 9-line App.jsx file to *that*.

Right away, you’ll notice we’re importing quite a bit more from the React library, including the Suspense component, which allows us to display a fallback component until the children have loaded.

In this case, we’re waiting for the Routes to load. Once they are loaded, we map them into the React Router component, which uses the browser location, and renders the associated component.

<Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        {routes.map((route, index) => (
          <Route
            key={index}
          path={route.path}
            element={<route.component />}
          />
        ))}
        </Routes>
     </Suspense>
 </Router>
Enter fullscreen mode Exit fullscreen mode

But where did we load those components? That’s what the useEffect() is for! In there, we take advantage of the Vite import.meta.globEager() to import any component that we create in our ./pages/ directory. That means if you create an /src/pages/Contact component, you’ll also automatically have a page at https://localhost:*PORT*/contact.

From there, we iterate over each path that we’ve found in our ./pages/ directory, and we assign the Pages with names by removing the ‘.jsx’ extension, and changing it to lowercase.

We also replace ‘Home’ with ‘/’, so that it navigates to our Homepage component at the root.

We then use the setRoutes() function to update the state with our newly created pages, and render them in the previously shows code.

useEffect(() => {
    function loadPages() {
      const context = import.meta.globEager('./pages/*.jsx'); //Imports all components in the ./pages/ directory
      const routeList = [];
      for (const path in context) {
        const module = context[path];
        const pageName = path.replace('./pages/', '').replace('.jsx', '');
        const routePath = pageName === 'Home' ? '/' : `\${pageName.toLowerCase()}\\`;
        const route = {
          path: routePath,
          component: module.default,
                    pageName,
        };
        routeList.push(route);
      }
      setRoutes(routeList);
    }
    loadPages();
  }, []);
Enter fullscreen mode Exit fullscreen mode

It’s important to remember that useEffect is a React Hook that lets you synchronize a component with an external system, and should be used for that purpose. In this case, the external system is the import.meta.globEager() which is a special Vite method that occurs asynchronously.

You can now visit Home (’/’), About (’/about’), and Login (’/Login), and you’ll be greeted with the very basic titles. Now that we have our pages set up, let’s start making things a bit more pretty.

Getting started with Material UI

While we really haven’t gotten into the meat of what we’re doing in terms of integration with Firebase and Auth quite yet, I’m the type of person who simply can’t work in LoFi. I don’t need it to be high design, but let’s start establishing our pages!

We’re going to start by creating a Layout context. This will enable us to keep our “always on” components, as well as our theme, well defined and easily accessible. Start by creating a new src/contexts/ folder, and create a new PageLayout.jsx file.

//PageLayout.jsx
import Grid from '@mui/material/Grid';
import Container from '@mui/material/Container';

const PageLayout = ({ children }) => (
    <Grid container direction="column" minHeight="100vh">
        <Grid item xs={12} py={4}>
      <Container component="main">
        {children}
      </Container>
        </Grid>
    </Grid>
);
export default PageLayout
Enter fullscreen mode Exit fullscreen mode

What we’ve made here is a context component. It accepts the children which are nested inside of it, and applies it’s context universally. We simply have to head over to our App.jsx, and wrap our pages in it.

Now, every one of our pages will have consistent spacing and basic layout. But let’s make this a bit more powerful. We’re going to use our context to add a universal Navigation Bar, and a Footer, providing users with a consistent browsing experience.

//App.jsx
import PageLayout from './contexts/PageLayout';
//...Other code
return (
    <Router>
      <PageLayout>
        <Suspense fallback={<div>Loading...</div>}>
          <Routes>
            {routes.map((route, index) => (
              <Route
                key={index}
                path={route.path}
                element={<route.component />}
              />
            ))}
          </Routes>
        </Suspense>
        </PageLayout>
    </Router>
  );
Enter fullscreen mode Exit fullscreen mode
//PageLayout.jsx

import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Container from '@mui/material/Container';

const Navbar = () => (
  <AppBar position="static">
    <Toolbar>
      <Typography variant="h6" component="div">
        My App
      </Typography>
    </Toolbar>
  </AppBar>
);

const Footer = () => (
  <Grid item xs={12} sx={{ py: 3, mt: 'auto', backgroundColor: '#f8f8f8' }}>
    <Container maxWidth="sm">
      <Typography variant="body2" color="text.secondary" align="center">
        © {new Date().getFullYear()} My first FERN App!
      </Typography>
    </Container>
  </Grid>
);

const PageLayout = ({ children }) => (
  <Grid container direction="column" minHeight="100vh">
    <Grid item xs={12}>
      <Navbar />
    </Grid>
    <Grid item xs={12} py={4}>
      <Container component="main">
        {children}
      </Container>
    </Grid>
    <Footer />
  </Grid>
);

export default PageLayout;
Enter fullscreen mode Exit fullscreen mode

So in here, we’ve imported the necessary components from MUI, we’ve created a Footer which fetches the current year and returns it as our copyright date, we’ve built a Nav Bar (we’ll get to the “Nav” part soon), and we’ve added those components into our PageLayout.

If everything was implemented correctly, it should look like this:

A browser displaying our home page with a Nav Bar and footer

So that’s at least an improvement over the white blank page we were seeing earlier. But now that we have the Nav Bar in place, it should at least have some functionality. In App.jsx, update the PageLayout component, and pass in the our routes state, and update the PageLayout.jsx to pass the routes into the NavBar

//App.jsx
<Router>
    <PageLayout routes={routes}>
        {/*...Routes*/}
    </PageLayout>
</Router>
Enter fullscreen mode Exit fullscreen mode

Now, we can access the routes in the NavBar object, and set it up to help us actually navigate!

Now, once we’re done, our NavBar component is going to be quite large.

//PageLayout.jsx
const PageLayout = ({children, routes}) => (
    <Grid container direction="column" minHeight="100vh">
        <Grid item xs={12}>
        <Navbar routes={routes} />
    </Grid>
    <Grid item xs={12} py={4}>
      <Container component="main">
        {children}
      </Container>
    </Grid>
    <Footer />
  </Grid>
);
Enter fullscreen mode Exit fullscreen mode

Before we start building it out any further, let’s move it into it’s own functional component. Create the /src/components directory, and then the NavBar.jsx file inside. Update your PageLayout.jsx to look like this:

//PageLayout.jsx
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Container from '@mui/material/Container';
import Navbar from '../components/NavBar';

const Footer = () => (
  <Grid item xs={12} sx={{py: 3, mt: 'auto', backgroundColor: '#f8f8f8'}}>
    <Container maxWidth="sm">
      <Typography variant="body2" color="text.secondary" align="center">
        © {new Date ().getFullYear ()} My first FERN App!
      </Typography>
    </Container>
  </Grid>
);

const PageLayout = ({children, routes}) => (
  <Grid container direction="column" minHeight="100vh">
    <Grid item xs={12}>
      <Navbar routes={routes} />
    </Grid>
    <Grid item xs={12} py={4}>
      <Container component="main">
        {children}
      </Container>
    </Grid>
    <Footer />
  </Grid>
);

export default PageLayout;
Enter fullscreen mode Exit fullscreen mode

And we’ll build our our NavBar component. Currently, it should look something like this:

// /components/NavBar.jsx
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';

const Navbar = () => (
  <AppBar position="static">
    <Toolbar>
      <Typography variant="h6" component="div">
        My App
      </Typography>
    </Toolbar>
  </AppBar>
);
Enter fullscreen mode Exit fullscreen mode

We’re going to update this component by: accepting the routes prop. Creating a Menu, and iterating over each of the routes to create a menu item.

const Navbar = ({routes}) => {
    return (
        <AppBar position="static">
            <Toolbar >
                <Typography variant="h6" component="div">
                    My App              
                </Typography>
        <IconButton edge="end" sx={{ml:'auto'}}>
            <MenuIcon />
        </IconButton>
                <Menu>
                    {routes.map ((route, index) => {
                        return (
                <MenuItem
                  key={index}
                  component={Link}
                  to={route.path}
                  onClick={handleMenuClose}
                >
                  {route.pageName}
                </MenuItem>
                            );
            })}
                </Menu>
      </Toolbar>
        </AppBar>)
};
Enter fullscreen mode Exit fullscreen mode

In here, we have created our Menu, iterated over each of the menu items, and used the pageName of each route as the item. Now, we need to add some logic to this to make it actually work!

const Navbar = ({routes}) => {
    const [navMenuAnchorEl, setNavMenuAnchorEl] = useState (null);

    const handleMenuClick = e => {
        setNavMenuAnchorEl (e.currentTarget);
  };
  const handleMenuClose = () => {
        setNavMenuAnchorEl (null);
    };
// ...
<Menu
    anchorEl={navMenuAnchorEl}
    open={!!navMenuAnchorEl}
  MenuListProps={{
      onMouseLeave: handleMenuClose,
  }}>
}
Enter fullscreen mode Exit fullscreen mode

Here, we have defined our handleMenuClick to assign our Menu’s Anchor Element to the clicked button icon as our navMenuAnchorEl. We have created the logic to open the menu when navMenuAnchorEl isn’t nullish, and when our mouse exits the Menu hover, it will close the menu by resetting the anchorEl state.

Our app with a Menu on the top-right

Ok awesome! But we probably don’t want our homepage in there. Let’s use the “My App” on the left to navigate home, and remove the Home item from our popover menu:

return (
    <AppBar position="static">
      <Toolbar>
        <Typography variant="h6" component={Link} to="/" sx={{textDecoration:'none', color:'white'}}>
          My App
        </Typography>
        <IconButton edge="end" onClick={handleMenuClick} sx={{ml: 'auto'}}>
          <MenuIcon />
        </IconButton>
        <Menu
          anchorEl={navMenuAnchorEl}
          open={!!navMenuAnchorEl}
          MenuListProps={{
            onMouseLeave: handleMenuClose,
          }}
        >
          {routes.map ((route, index) => {
            if (route.path === '/') return;
            return (
              <MenuItem
                key={index}
                component={Link}
                to={route.path}
                onClick={handleMenuClose}
              >
                {route.pageName}
              </MenuItem>
            );
          })}
        </Menu>
      </Toolbar>
    </AppBar>
  );
Enter fullscreen mode Exit fullscreen mode

We’ve added the Link component to our Typography in order to keep it the expected colour. In our Menu component, we are skipping any routes that navigate back to ‘/’, you could also use if (route.pageName === 'Home') if you prefer. Same effect.

Updated app with better menu
Great. Now we have some basics in place. Let’s start building our Auth logic!

Firebase Auth Integration

Firebase Auth is a massively powerful, drop-in solution with OAuth2.0 support, support for Sign in with Google, Twitter, SSO, SAML, and basic username and password.

That’s what we’re going to be using here. To get started, head over to console.firebase.google.com and create a new Project.

Getting started with Firebase

Click on the Web icon, give your app a name, and then on the next page, you should see the instructions to add the Firebase SDK. We’re going to take these values and create our Firebase instance for our app. In /src/ create a new firebaseConfig.js. It will look something like this:

// .../src/firebaseConfig.js

import { initializeApp } from "firebase/app";
import { getDatabase } from "firebase/database";

const firebaseConfig = {
  apiKey: "YOUR-API-KEY",
  authDomain: "YOUR-PROJECT.firebaseapp.com",
  projectId: "YOUR-PROJECT",
  storageBucket: "YOUR-PROJECT.appspot.com",
  messagingSenderId: "YOURID",
  appId: "YOURAPPID",
  databaseURL: "https://DATABASENAME.firebaseio.com",
};

const app = initializeApp (firebaseConfig);
const auth = getAuth(app);
const db = getDatabase(app);

export {app, auth, db};
Enter fullscreen mode Exit fullscreen mode

So here, we have set up our Firebase App (app), our Auth, and our db, all in one file. To create your RTDB, in Firebase, navigate to Build ⇒ Realtime Database. You’ll then be presented with the Database URL, which you can populate into your firebaseConfig.js. Take this time to also set up Authentication. For this Walkthrough, you will need to activate Email/Password sign in.

Now we’re going to wrap our App in the Auth. Create a new file in /src/contexts called AuthProvider.jsx. Much like our PageLayout.jsx, this is a Context which will provide our User’s auth status to the entire application.

// ./src/contexts/AuthProvider.jsx
import React, { createContext, useState, useEffect } from "react";
import { auth } from "../firebaseConfig";

export const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);
  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged(user => {
      setCurrentUser(user);
    });
    return () => unsubscribe();
  }, []);

  return (
    <AuthContext.Provider value={{ currentUser }}>
      {children}
    </AuthContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

So here, we are creating a context which all of the child elements can read from and manipulate. We’re using the useEffect hook to listen for any changes in the authentication state, and keeping the context up to date with the currentUser. When the component is unmounted or the effect dependencies change, we remove the observer by calling the unsubscribe function. And, just like with our PageLayout.jsx, we’re going to wrap the App in this context.

//App.jsx
//...other imports

import {AuthProvider} from './contexts/AuthProvider';

//...other code

return (
        <Router>
      <AuthProvider>
        <PageLayout routes={routes}>
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              {routes.map ((route, index) => (
                <Route
                  key={index}
                  path={route.path}
                  element={<route.component />}
                />
              ))}
            </Routes>
          </Suspense>
        </PageLayout>
      </AuthProvider>
    </Router>
  );
Enter fullscreen mode Exit fullscreen mode

Now that we have our AuthProvider in place, let’s create our Sign in and Sign Up page! Let’s start by creating our Login In and Sign Up forms.

// LoginForm.jsx
import React, { useState } from "react";
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
import { useNavigate } from "react-router-dom";
import { TextField, Button, Typography } from "@mui/material";

const LoginForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const navigate = useNavigate();
  const auth = getAuth();

  const handleSignIn = async (e) => {
    e.preventDefault();
    try {
      await signInWithEmailAndPassword(auth, email, password);
      navigate("/dashboard");
    } catch (error) {
      console.error("Error signing in", error);
    }
  };
  return (
    <form onSubmit={handleSignIn}>
      <Typography variant="h5">Sign In</Typography>
      <TextField
        type="email"
        label="Email"
        variant="outlined"
        value={email}
        onChange={e => setEmail (e.target.value)}
        fullWidth
        margin="normal"
      />
      <TextField
        type="password"
        label="Password"
        variant="outlined"
        value={password}
        onChange={e => setPassword (e.target.value)}
        fullWidth
        margin="normal"
      />
      <Button type="submit" variant="contained" color="primary" fullWidth>
        Sign In
      </Button>
    </form>
  );
};

export default LoginForm;
Enter fullscreen mode Exit fullscreen mode
// /components/SignUpForm.jsx
import React, { useState, useContext } from "react";
import { auth } from "../firebaseConfig";
import { AuthContext } from "../contexts/AuthProvider";
import { useNavigate } from "react-router-dom";
import { TextField, Button, Typography } from "@mui/material";

const SignUpForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { currentUser } = useContext(AuthContext);
  const navigate = useNavigate();

  const handleSignUp = async (e) => {
    e.preventDefault();
    try {
        await auth.createUserWithEmailAndPassword(email, password);
        navigate("/");
      } catch (error) {
        console.error("Error signing up", error);
      }
    };

  return (
    <form onSubmit={handleSignUp}>
      <Typography variant="h5">Sign Up</Typography>
      <TextField
        type="email"
        label="Email"
        variant="outlined"
        value={email}
        onChange={e => setEmail (e.target.value)}
        fullWidth
        margin="normal"
      />
      <TextField
        type="password"
        label="Password"
        variant="outlined"
        value={password}
        onChange={e => setPassword (e.target.value)}
        fullWidth
        margin="normal"
      />
      <Button type="submit" variant="contained" color="primary" fullWidth>
        Sign Up
      </Button>
    </form>
  );
};

export default SignUpForm;
Enter fullscreen mode Exit fullscreen mode

Both of these components are relatively straightforward. We’re importing the firestore/auth library, and getting our up-to-date Auth object, which we can then use to Log in or Sign up. Now, we need to render these at our Login Page!

// Login.jsx
import React, { useState } from "react";
import { Box, Button, Paper } from "@mui/material";
import LoginForm from "../components/LoginForm";
import SignUpForm from "../components/SignUpForm";

const Login = () => {
  const [showLoginForm, setShowLoginForm] = useState(true);

  const handleToggleForm = () => {
    setShowLoginForm(!showLoginForm);
  };
  return (
    <Box
      display="flex"
      flexDirection="column"
      alignItems="center"
    >
      <Paper elevation={3} sx={{ p: 4 }}>
        {showLoginForm ? <LoginForm /> : <SignUpForm />}
        <Box mt={2}>
          <Button onClick={handleToggleForm}>
            {showLoginForm
              ? "Don't have an account? Sign up"
              : "Already have an account? Sign in"}
          </Button>
        </Box>
      </Paper>
    </Box>
  );
};

export default Login;
Enter fullscreen mode Exit fullscreen mode

Here, we’ve imported the Login and Signup form, and we’re using a basic state to determine which form is being rendered. We have our handler function, which, when clicked, simple changes the state from it’s current state to the opposite. Then, depending on the state, we either render the Sign Up or Login Form. It should look like this!

App at Sign up Login Screen

Now, before you go and test this out, we have to make sure there’s a way that we’re testing it’s success. Right now, both the Sign up and Log In forms redirect you to the Home page after a successful sign in. That’s not really helpful! We’re going to create another context. This one will help to verify that the user is signed in, and if they aren’t we’ll shoot them off to the login page!

Let’s create another file in /src/context, this one called PrivatePage.jsx.

// PrivatePage.jsx
import { useContext } from "react";
import { Navigate } from "react-router-dom";
import { AuthContext } from "./AuthProvider";

const PrivatePage = ({ component: Component, ...rest }) => {
  const { currentUser } = useContext(AuthContext);

  return currentUser ? <Component {...rest} /> : <Navigate to="/login" replace />;
};

export default PrivatePage;
Enter fullscreen mode Exit fullscreen mode

So here, we’ve imported the AuthContext, we’re accepting the component and any passed props (…rest), verifying that the currentUser exists (which means they’re signed in), otherwise redirecting them to the ‘/login’ page.

We’re also going to want to create a Private page, which is only accessible to a logged in user. Let’s create ./pages/Dashboard.jsx

// Dashboard.jsx
import React, { useContext } from "react";
import { Box, Typography } from "@mui/material";
import { AuthContext } from "../contexts/AuthProvider";

const Dashboard = () => {
  const { currentUser } = useContext(AuthContext);
  return (
    <Box
      display="flex"
      flexDirection="column"
      alignItems="center"
    >
      <Typography variant="h3" mb={3}>
        Welcome to the Dashboard!
  /* // PROBLEM HERE */
  </Typography>
      <Typography variant="h5" mb={2}>
        {currentUser ? `Logged in as ${currentUser.email}` : ""}
      </Typography>
    </Box>
  );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

This component will display the logged in User’s email address with a very basic welcome message. Since we want this to be a Private Page, only accessible to signed in users, we’re going to have to specify it in our App.jsx file.

//App.jsx
import PrivatePage from './contexts/PrivatePage';
//...
useEffect (() => {
    function loadPages () {
      const context = import.meta.globEager ('./pages/*.jsx');
      const routeList = [];
      for (const path in context) {
        const module = context[path];
        const pageName = path.replace ('./pages/', '').replace ('.jsx', '');
        const routePath = pageName === 'Home'
          ? '/'
          : `/${pageName.toLowerCase ()}`;
        const isPrivate = pageName === 'Dashboard'; // Set isPrivate to true for Dashboard or other private pages
        const route = {
          path: routePath,
          component: module.default,
          pageName,
          isPrivate,
        };
        routeList.push (route);
      }
      setRoutes (routeList);
    }
    loadPages ();
  }, []);

//...

<Routes>
    {routes.map (
      (route, index) =>
        route.isPrivate ? 
                <Route
            key={index}
            path={route.path}
          element={<PrivatePage component={route.component} />}
         /> : 
                 <Route
             key={index}
           path={route.path}
           element={<route.component />}
          />
       )}
</Routes>

//...
Enter fullscreen mode Exit fullscreen mode

Now, if we try to navigate to /dashboard without a user logged in, it will redirect us back to the homepage. We have a great way of knowing if we’re successfully logged in!

Let’s update our Login and Sign Up forms to redirect us there after successful login or sign up.

//LoginForm.jsx
const handleSignIn = async (e) => {
    e.preventDefault();
    try {
      await currentUser.signInWithEmailAndPassword(email, password);
      navigate("/dashboard"); //Updated with new location
    } catch (error) {
      console.error("Error signing in", error);
    }
  };
Enter fullscreen mode Exit fullscreen mode
//SignUpForm.jsx
const handleSignUp = async (e) => {
    e.preventDefault();
    try {
        await currentUser.createUserWithEmailAndPassword(email, password);
        navigate("/dashboard"); //Updated with new location
        } catch (error) {
            console.error("Error signing up", error);
      }
    };
Enter fullscreen mode Exit fullscreen mode

Once you have those completed, go sign up, and try it out! If you’re logged in and everything is set up correctly, you should see something like this!

Logged In page

Now, right away, you’ll notice we forgot to include a way to sign out. Let’s create that function right now. Update the AuthProvider.jsx to include:

import { useNavigate } from "react-router-dom";

export const AuthProvider = ({ children }) => {
    const navigate = useNavigate(); 
    //...
    const signOut = () => {
    auth.signOut();
        navigate("/login");
  }
  return (
    <AuthContext.Provider value={{ currentUser, signOut }}>
      {children}
    </AuthContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now, we can call signOut from any of our elements or pages within the provider. A natural place to add this is in the NavBar Menu.

Update the NavBar to import the Auth Context, and add the Sign Out menu item:

// NavBar.jsx
import {useState, useContext} from 'react';
//...
import { AuthContext } from '../contexts/AuthProvider';

const Navbar = ({routes}) => {
    const {signOut} = useContext(AuthContext);

    //...Handlers

return (
    <AppBar position="static">
      <Toolbar>
        <Typography
          variant="h6"
          component={Link}
          to="/"
          sx={{textDecoration: 'none', color: 'white'}}
        >
          My App
        </Typography>
        <IconButton edge="end" onClick={handleMenuClick} sx={{ml: 'auto'}}>
          <MenuIcon />
        </IconButton>
        <Menu
          anchorEl={navMenuAnchorEl}
          open={!!navMenuAnchorEl}
          MenuListProps={{
            onMouseLeave: handleMenuClose,
          }}
        >
          {routes.map ((route, index) => {
            if (route.path === '/') return;
            return (
              <MenuItem
                key={index}
                component={Link}
                to={route.path}
                onClick={handleMenuClose}
              >
                {route.pageName}
              </MenuItem>
            );
          })}
          <MenuItem onClick={signOut} />
        </Menu>
      </Toolbar>
    </AppBar>
  );
Enter fullscreen mode Exit fullscreen mode

Something you might have noticed now, our Menu is getting a little bit cluttered. We’re displaying Login even when a user is signed in, and we have a Sign Out even if a user isn’t signed in. Let’s clean up this Menu a bit. We really want to be able to Navigate to the About page, the Login Page if not logged in and the Sign Out should be available if logged in. We’ll include the Dashboard link in there as well. It’s a nice, consistent way to navigate around.

//NavBar.jsx
import {useState, useContext} from 'react';
import {
  AppBar,
  Toolbar,
  Typography,
  Menu,
  MenuItem,
  IconButton,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import {Link} from 'react-router-dom';
import {AuthContext} from '../contexts/AuthProvider';

const Navbar = () => {
  const {signOut, currentUser} = useContext (AuthContext);
  const [navMenuAnchorEl, setNavMenuAnchorEl] = useState (null);
  const handleMenuClick = e => {
    setNavMenuAnchorEl (e.currentTarget);
  };
  const handleMenuClose = () => {
    setNavMenuAnchorEl (null);
  };
  return (
    <AppBar position="static">
      <Toolbar>
        <Typography
          variant="h6"
          component={Link}
          to="/"
          sx={{textDecoration: 'none', color: 'white'}}
        >
          My App
        </Typography>
        <IconButton edge="end" onClick={handleMenuClick} sx={{ml: 'auto'}}>
          <MenuIcon />
        </IconButton>
        <Menu
          anchorEl={navMenuAnchorEl}
          open={!!navMenuAnchorEl}
          MenuListProps={{
            onMouseLeave: handleMenuClose,
          }}
        >
          <MenuItem component={Link} to={'/about'}>About</MenuItem>
          <MenuItem component={Link} to={'/dashboard'}>Dashboard</MenuItem>
          {currentUser
            ? <MenuItem onClick={signOut}>Sign out</MenuItem>
            : <MenuItem component={Link} to={'/login'}>Log In</MenuItem>}
        </Menu>
      </Toolbar>
    </AppBar>
  );
};
export default Navbar;
Enter fullscreen mode Exit fullscreen mode

Updated Menu

Now, we’ve neatly defined the Menu Items, and our NavBar is tidy, consistent, and contextual. We have a Dashboard for our customers, and we have our Auth all set up.

Now. Let’s get some data entry and manipulation in here!

Setting up the Database

We’ve spent a good amount of time working in React, now. It seems like a great time to navigate back up a level, and head into our server.

Last we left it, it looked like this:

//index.js

import express, {json} from 'express';
import * as dotenv from 'dotenv'
import cors from 'cors';

dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(json());

app.get('/', (req, res) => res.send(`Hello World!`));

app.listen(port, () => console.log(`Express app listening on ${port}`))
Enter fullscreen mode Exit fullscreen mode

We’re going to now set this up act as our API for our front end. We’re going to create endpoints to send and retrieve data, as well as validate our user request is authentic.

Just as we did in the React front end, we’re going to set up our Firebase instance.

In the root of your app, create firebase.js,

//firebase.js

import {initializeApp} from 'firebase/app';
import {getDatabase} from 'firebase/database';
import {getAuth} from 'firebase/auth';

const firebaseConfig = {
  apiKey: "YOUR-API-KEY",
  authDomain: "YOUR-PROJECT.firebaseapp.com",
  projectId: "YOUR-PROJECT",
  storageBucket: "YOUR-PROJECT.appspot.com",
  messagingSenderId: "YOURID",
  appId: "YOURAPPID",
    databaseURL: "https://DATABASENAME.firebaseio.com",
};

const firebase = initializeApp (firebaseConfig);
const auth = getAuth(firebase);
const db = getDatabase(firebase);

export {firebase, auth, db};
Enter fullscreen mode Exit fullscreen mode

You’ll notice that we named this instance ‘firebase’ instead of ‘app’. That’s to avoid collisions with our express app in index.js, where we will import our app, ensuring it’s initialized as soon as the app starts.

We’re going also going to define two routes one GET, one POST, both to ‘/data’. We’ll start by defining the routes. In your root directory, create ‘userRoutes.js’.

import express from 'express';
const router = express.Router ();
import {db} from './firebase.js';
import {ref, set, get, child} from 'firebase/database';

router.get ('/data', async (req, res, next) => {
  const {userId} = req.body;
  try {
    get (ref (db, 'users', + userId)).then (snapshot => {
      if (snapshot.exists ()) {
        console.log (snapshot.val ());
        res.status (200).json (snapshot.val ());
      } else {
        console.log ('No data available');
        res.sendStatus (204);
      }
    });
  } catch (error) {
    next (new Error (error.message));
  }
});

router.post ('/data', async (req, res, next) => {
  const {userId, userData} = req.body;
  try {
    await set (ref (db, 'users/' + userId + '/' + userData.id), userData)
      .then (() => {
        res.status (200).json ({...userData});
      })
      .catch (e => {
        throw e;
      });
  } catch (error) {
    next (new Error (error.message));
  }
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Here, we are defining our /data routes. In our GET route, we are checking the body for the userId, and then returning a snapshot of the data in the database under that user ID.

For the POST route, we are extracting userId and userData in similar fashion, then setting the data. We also return the userData object for validation on the client side.

You will see here we are also passing the errors to next(). We’re going to set up central error handling in our index.js. This helps us keep our code clean, performant, and readable.

//index.js
import express, {json} from 'express';
import * as dotenv from 'dotenv';
import cors from 'cors';
import {firebase} from './firebase.js';
import userRoutes from './userRoutes.js';
dotenv.config ();
const app = express ();
const port = process.env.PORT || 3000;

app.use (cors ());
app.use (json ());

app.use (userRoutes);
app.use ('*', (req, res, next) => {
  console.log (req.baseUrl, req.method);
  next ();
});

app.use ((error, req, res, next) => {
  res.status (500).json ({error: error.message});
});

app.listen (port, () => console.log (\`Express app listening on ${port}\`));
Enter fullscreen mode Exit fullscreen mode

Now, if you head to Postman, you should be able to post to localhost:3000/data with a data structured like this:

And you’ll be able to set some test data to read.

Send a GET and it will retrieve each post

But wait!’ I hear you calling out ‘I don’t want to use a userId every call. And this isn’t very secure!’

You’re right, voice of public opinion! You can’t set a body on a post route, and query params are lame!

{
    "userId":"testUser1",
     "userData": {
        "foo": "bar",
        "id": 1234,
        "key": "item"
    }
}
Enter fullscreen mode Exit fullscreen mode
{
    "testUser1": {
        "1234": {
            "foo": "bar",
            "id": 1234,
            "key": "item"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Verify Token Middleware

We need a way to consistently validate that the user is who they say they are, and to access their userId in an easy, uniform fashion. First, we’re going to need to get our Firebase Admin credentials. In the Firebase console, navigate to Project settings, Service accounts, and click Generate Key. Create a new serviceAccount.json at your project root (make sure to add this to your .gitignore) and copy the contents of the download into that file.

Now that we have our service account credentials, we can initialize our Admin app, and create our middleware. Make a new directory called /middlewares/ and create a file called verifyToken.js.

The verifyToken.js is fairly straightforward:

import admin from 'firebase-admin';
import serviceAccount from '../serviceAccount.json' assert { type: "json" };

admin.initializeApp ({
    credential: admin.credential.cert (serviceAccount),
  });
const verifyToken = async (req, res, next) => {
  const token = req.headers.authorization.split('Bearer ')[1];
  if (!token) {
    return res.status(401).send('Unauthorized');
  }
  try {
    const decodedToken = await admin.auth()
    .verifyIdToken(token)
    req.body.user = decodedToken;
    req.body.userId = decodedToken.uid;

    next();
  } catch (error) {
    console.error(error);
    res.status(403).send('Forbidden');
  }
};

export default verifyToken;
Enter fullscreen mode Exit fullscreen mode

We import the service account, initialize our Admin app, then use it to decode the Bearer token that we will pass in with our front end API call. This also extracts the decodedToken, and sets the req.user with the decodedToken object. We can then access the whole token object at req.body.user, and the userId at req.body.userId.

Now we have somewhere that will store data, a way to read that data, and a way verify that the user requesting the data is who they say they are.

Let’s head back to the Front End, and build out our requests!

Connecting the Front-end and Back-end

Alright, we have our Back end ready to go, now we need to input and display our data entries. i’ve been thinking about building a better grocery app for a while now, so I’ll start here. Because we were so ambiguous in how we defined our back-end, it’s incredibly flexible on the front end. The only mandatory field we must pass is an ID.
With that in mind, let’s get to work.

Earlier, we very briefly mentioned React Query. Let’s talk a bit more about why it’s so great. The big ones for me: 1. It handles caching and background updates - some of the biggest headaches in development, solved. 2. It works with *****any***** promise. As a result, it’s an exceptional state manager. You don’t need to write repetitive and annoying reducers, you just tell React Query where to get the data, and it gets it.

You might not see why those are so powerful right away, but as your projects grow, you’ll want a good way to share stateful data between components. React Query helps with that a lot.

First, we’re going to update our App.jsx to include our Query Client Provider.

// App.jsx

//... other imports

import { QueryClient, QueryClientProvider } from 'react-query';

const App = () => {
    {/*Other code here*/}

    const queryClient = new QueryClient();

    return (
    <Router>
      <QueryClientProvider client={queryClient}>
        <AuthProvider>
          <PageLayout routes={routes}>
            <Suspense fallback={<div>Loading...</div>}>
              <Routes>
                {routes.map(
                  (route, index) =>
                    route.isPrivate
                      ? <Route
                          key={index}
                          path={route.path}
                          element={<PrivatePage component={route.component} />}
                        />
                      : <Route
                          key={index}
                          path={route.path}
                          element={<route.component />}
                        />
                )}
              </Routes>
            </Suspense>
          </PageLayout>
        </AuthProvider>
      </QueryClientProvider>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, we’re setting up our queryClient with no options, but you can customize this to suit your needs. You can define cacheTimes, staleTimes, and even throw universal errors from your QueryProvider or on a per “useQuery” or per “useMutation” instance. Now that we have that set up, let’s start building our Queries and Mutations.

Since we’re building a grocery list App, we’re going to need to add our items. Let’s start by defining our fetch function. Create a new folder '/src/api/', and make a file called addGroceryItem.js

export const addGroceryItem = async (groceryItem, token) => {
    const response = await fetch("/data", {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ${token}',
      },
      body: JSON.stringify({userData: groceryItem}),
    });

    if (!response.ok) {
      throw new Error('Error adding grocery item: ${response.statusText}');
    }

    const data = await response.json();
    return data;
  };
Enter fullscreen mode Exit fullscreen mode

Here, we have defined a a function which accepts a passed in groceryItem and token, which we are then sending to our back-end for retention.

Now, we want to use this with our React Query Provider, so we’re going to build a custom Hook to handle this mutation. Let’s create ‘/src/hooks’, and create useAddGroceryItem.js

//useAddGroceryItem.js
import { useMutation } from 'react-query';
import { addGroceryItem } from '../api/addGroceryItem';

export const useAddGroceryItem = (token) => {
  const mutation = useMutation((groceryItem) => addGroceryItem(groceryItem, token), {
    onError: (error) => {
      console.log('An error occurred while adding the grocery item:', error);
    },
    onSuccess: (data) => {
      console.log('Grocery item added successfully:', data);
    },
  });
  return mutation;
};
Enter fullscreen mode Exit fullscreen mode

Now that we have everything set up to post a grocery item, let’s build our logic to fetch our grocery items! Much like the above, we’ll create fetchGroceryItems.js

// api/fetchGroceryItems.js
export const fetchGroceryItems = async (token) => {
  const response = await fetch(\`/data\`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': \`Bearer ${token}\`,
    },
  });

  if (!response.ok) {
    throw new Error(\`Error fetching grocery items: ${response.statusText}\\`);
  }
  const data = await response.json();
  return data;
};
Enter fullscreen mode Exit fullscreen mode

And, again, like the above, we will create our useFetchGroceryItems.js hook

// hooks/useFetchGroceryItems.js
import { useQuery } from 'react-query';
import { fetchGroceryItems } from '../api/grocery';

export const useFetchGroceryItems = (token) => {
  return useQuery('groceryItems', () => fetchGroceryItems(token));
};
Enter fullscreen mode Exit fullscreen mode

I want to take a second to pause here and reflect on what’s happening here. We have defined 2 functions, 1 which mutates data and 1 which returns it. React Query doesn’t care what this data is, where it comes from or how we choose to display it. It just needs to receive a Promise and optionally an Error. In this case, we are polling our ‘/data’ route, which we set up earlier, but you can (and likely will) use it for all kinds of state and data management.

Next up, we’re going to build our input form, and display it in our Dashboard. I’m going to create a new component called GroceryItemInputForm.jsx

import React, {useState} from 'react';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Button from '@mui/material/Button';
import Grid from '@mui/material/Grid';

const commonMeasurements = [
  'piece',
  'fluid ounce',
  'cup',
  'pint',
  'quart',
  'gallon',
  'milliliter',
  'liter',
  'ounce',
  'pound',
  'gram',
  'kilogram',
];

const GroceryItemInputForm = ({token}) => {
  const [name, setName] = useState ('');
  const [quantity, setQuantity] = useState ('');
  const [measurement, setMeasurement] = useState ('');

  const handleSubmit = e => {
    e.preventDefault ();
    console.log ({name, quantity, measurement});
  };

  return (
    <Grid
      component="form"
      onSubmit={handleSubmit}
      noValidate
      autoComplete="off"
      container
      justifyContent={'space-between'}
      rowGap={1}
    >
      <Grid
        item
        component={TextField}
        label="Item Name"
        value={name}
        onChange={e => setName (e.target.value)}
        xs={8}
        sm={4}
        fullWidth
      />
      <Grid
        item
        component={TextField}
        label="Quantity"
        value={quantity}
        onChange={e => setQuantity (e.target.value)}
        fullWidth
        xs={3}
      />
      <Grid
        item
        component={TextField}
        select
        label="Measurement"
        value={measurement}
        onChange={e => setMeasurement (e.target.value)}
        sm={2}
        xs={8}
        fullWidth
      >
        {commonMeasurements.map (unit => (
          <MenuItem key={unit} value={unit}>
            {unit}{quantity > 1 ? 's' : ''}
          </MenuItem>
        ))}
      </Grid>
      <Grid
        component={Button}
        type="submit"
        variant="contained"
        color="primary"
        item
        xs={3}
        sm={2}
      >
        Add
      </Grid>
    </Grid>
  );
};
export default GroceryItemInputForm;
Enter fullscreen mode Exit fullscreen mode

Here, we’ve used MUI’s Grid to build a nice, responsive form, which includes a dropdown menu for various common measurements. Right away, this just logs the entry for us, but it’s a great start. We’ll import this into our Dashboard.jsx for use, where we’ll also pass in the user token. To make sure we’re using the most recent token, we’re going to get it from the firebase/auth library, using the getAuth() method.

// Dashboard.jsx
import React, {useContext} from 'react';
import {Box, Typography} from '@mui/material';
import {getAuth} from 'firebase/auth';

import GroceryItemInputForm from '../components/GroceryItemInputForm';

const Dashboard = () => {

    const {currentUser} = getAuth ();

    return (
    <Box display="flex" flexDirection="column" alignItems="center">
      <Typography variant="h3" mb={3}>
        Welcome to the Dashboard!
      </Typography>
      <Typography variant="h5" mb={2}>
        {currentUser ? \`Logged in as ${currentUser.email}\\` : ''}
      </Typography>
      <GroceryItemInputForm token={currentUser.accessToken}/>
    </Box>
  );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Perfect, it’s now imported into the dashboard, which should look like this:

Updated Dashboard with Grocery Item input form

Now, let’s hook it up to our API! Head back into the GroceryItemInputForm.jsx, lets import our custom useAddGroceryItem hook.

import { useAddGroceryItem } from '../hooks/useAddGroceryItem';

Enter fullscreen mode Exit fullscreen mode

Then, inside the GroceryItemInputForm component, we’re going to accept the passed token, and create our authenticated mutation instance.

// /components/GroceryItemInputForm.jsx
//...Other Code

const GroceryItemInputForm = ({token}) => {
  const [name, setName] = useState ('');
  const [quantity, setQuantity] = useState ('');
  const [measurement, setMeasurement] = useState ('');
  const addGroceryItemMutation = useAddGroceryItem(token);

  const handleSubmit = e => {
    e.preventDefault ();
        const newItem = {name, quantity, measurement, id: Date.now ()};
        addGroceryItemMutation.mutate (newItem, {
      onSuccess: () => {
        setName ('');
        setQuantity ('');
        setMeasurement ('');
      },
    });  
    };
//...Other Code
}
Enter fullscreen mode Exit fullscreen mode

Awesome! Now we can update our grocery list using our form. Notice that we’ve also included the onSuccess handler, which in this case resets the form back to default. You can use this to display a success message, navigate users as needed, and others. You also have available the onError which which can offer error handling on a per-mutation instance, and onSettled, which runs regardless of Success or Error. Read more about Mutations and side effects here.

Now that we have a way to set our grocery list, we need a way to display our Grocery items! Lets create the GroceryList.jsx component

// GroceryList.jsx
import React from 'react';
import {useFetchGroceryItems} from '../hooks/useFetchGroceryItems';
import {
  CircularProgress,
  ListItem,
  ListItemText,
  Typography,
  Grid,
  List,
} from '@mui/material';

const GroceryList = ({token}) => {
  const {data: groceryItems, error, isLoading} = useFetchGroceryItems (token);
  if (isLoading) {
    return (
      <Grid container py={4}>
        <Grid item xs={12} textAlign={'center'}>
          <CircularProgress />
        </Grid>
      </Grid>
    );
  }
  if (error) {
    return (
      <Grid container py={4}>
        <Grid item xs={12} textAlign={'center'}>
          <Typography variant="h6" color="error">
            Error: {error.message}
          </Typography>
        </Grid>
      </Grid>
    );
  }
  return (
    <Grid container>
      <Grid xs={12} component={List} container>
        {Object.values (groceryItems).map (item => (
          <Grid item xs={12} component={ListItem} key={item.id}>
            <ListItemText
              primary={item.name}
              secondary={\`${item.quantity} ${item.measurement}\`}
            />
          </Grid>
        ))}
      </Grid>
    </Grid>
  );
};

export default GroceryList;
Enter fullscreen mode Exit fullscreen mode

Alright, that’s a big component! with a lot going on. Let’s break it down.

As usual, we’re importing out necessary dependancies, as well as our useFetchGroceryItems custom hook. We’re taking in the token which we will pass in from the Dashboard, then we have this:

Now, if you haven’t taken the opportunity to head over to the React Query docs, now would be another great time to do so.

What we have here is a destructured Array, which we are pulling from the useFetchGroceryItems hook we built earlier.

const {
    data: groceryItems,
    error, 
    isLoading
} = useFetchGroceryItems (token);
Enter fullscreen mode Exit fullscreen mode

This allows us to check and handle our Loading status, check and handle any errors, and finally display the data. Awesome. Hit save, and we’ll head to the browser to add a couple of items to our list.

Dashboard with grocery items

That looks alright, but did you notice how long it took to update? Isn’t it called “Real Time Database”? What gives?

Well, remember how we talked about how React Query manages caching and state? Well now it’s time to learn about another awesome feature. Optimistic updates! Optimistic updates aren’t a new idea, nor are they overly complex. Essentially, you push the data to your state while pushing the data to the server you synchronize with. But what if your mutation fails, or the user cancels the mutation mid-process? That’s where React Query comes in handy. Let’s head back into our useAddGroceryItem.js file.

//useAddGroceryItem.js
import {useMutation} from 'react-query';
import {addGroceryItem} from '../api/addGroceryItem';

export const useAddGroceryItem = (token, queryClient) => {
  const mutation = useMutation ({
    mutationFn: groceryItem => addGroceryItem (groceryItem, token),
    onMutate: async (groceryItem) => {
      await queryClient.cancelQueries({queryKey : ['groceryItems']});
      const prevItems = queryClient.getQueryData(['groceryItems']);
      queryClient.setQueryData(['groceryItems'], (old) => {old, old[groceryItem.id] = groceryItem});
      return {prevItems}
    },
    onError: (error, groceryItem, context) => {
      console.log ('An error occurred while adding the grocery item: ', groceryItem, 'Error: ', error);
      queryClient.setQueryData(['groceryItems'], context.prevItems)
      return context.prevItems
    },
    onSuccess: (data, context) => {
    console.log ('Grocery item added successfully:', data);

    },
    onSettled: () => {
      queryClient.invalidateQueries({queryKey: ['groceryItems']})
    }
  });

  return mutation;
};
Enter fullscreen mode Exit fullscreen mode

Remember the side effects we talked about? Now we’re showing off their real power! Let’s break down what we’re doing here: First, we define the mutationFn, what we actually want the function to DO. In this case, we’re calling our addGroceryItem function and passing in the new groceryItem with our token. Next, we’re calling the “onMutate” side effect. This is where we are performing our optimistic updates. When the mutation is called, we cancel any current occurrences of the ‘groceryItems’ query, we then get a snapshot of the current state, and push our new item into that state. We then return the previous items, passing them into our error handler.

Here, if we run into an issue, we are able to quickly log the problem, and roll back to our previous state. We could also display an error message, or redirect the user as is necessary.

Then, when the Error and Success handling have completed, we invalidate the groceryItems query, and refetch from the back-end.

Now, one thing you’ll notice here, we’ve added in the queryClient variable. That needs to be retrieved and passed in from the dashboard.

// Dashboard.jsx
import React, {useContext} from 'react';
import {Box, Typography} from '@mui/material';
import GroceryItemInputForm from '../components/GroceryItemInputForm';
import GroceryList from '../components/GroceryList';
import {getAuth} from 'firebase/auth';
import { useQueryClient } from 'react-query';

const Dashboard = () => {
  const {currentUser} = getAuth ();
  const queryClient = useQueryClient()
  const token = currentUser.accessToken;
  return !currentUser
    ? ''
    : <Box display="flex" flexDirection="column" alignItems="center">
        <Typography variant="h3" mb={3}>
          Welcome to the Dashboard!
        </Typography>
        <Typography variant="h5" mb={2}>
          {currentUser ? \`Logged in as ${currentUser.email}\\` : ''}
        </Typography>
        <GroceryItemInputForm token={token} queryClient={queryClient}/>
        <GroceryList token={token} />
      </Box>;
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Here, we’ve imported the useQueryClient hook from React Query, which we use to call our contextual Query Client. Remember, since we’re calling this within a single context, the cache is shared, and the mutations and queries are all interconnected. Take another look at useFetchGroceryItems:

import { useQuery } from 'react-query';
import {fetchGroceryItems} from "../api/fetchGroceryItems"

export const useFetchGroceryItems = (token) => {
  return useQuery('groceryItems', () => fetchGroceryItems(token));
};
Enter fullscreen mode Exit fullscreen mode

The ‘groceryItems’ key that we’re using is unique to this hook, and it can be used to invalidate the items in this specific call, simply by using the string key and the QueryClient. Now, because we’re invalidated the query, we’re going to run into some scenarios where ‘groceryItems’ === null and we didn’t set up our GroceryList to handle that scenario.

//GroceryList.jsx

const GroceryList = ({token}) => {
  const {data: groceryItems, error, isLoading} = useFetchGroceryItems (token);

    if (isLoading || !groceryItems) {
        //Loading handler
    }

    if (error) {
        //Error handling
    }

    if (groceryItems) return (
        {/*Grocery List component*/}
    )
};

export default GroceryList;
Enter fullscreen mode Exit fullscreen mode

There you have it. We’ve added some elegant handling for adding items and automatically refreshing our list on update!

Our Grocery List App, with working list and add form.

Wrapping Up

By no means are we “done”, here. But we’re off to a great start. In this walkthrough, together we hooked up our Firebase Auth and Realtime Database, we created our Express server with our back-end API routes, we developed our React Front End to display and add our grocery items!

Also it runs on the Node Engine. Yay FERN!

Our next steps are going to include:

  • Adding a check mark to mark as completed
  • Edit items
  • Remove items
  • Removing completed items after a set period of time
  • Building the Front-end for Production
  • Containerizing
  • Hosting!

I really hope you enjoyed this walk-through, it’s my first time ever putting one together. Please let me know your thoughts and if you have any questions or comments!

You can find the completed github repo here.

Top comments (2)

Collapse
 
leggetter profile image
Phil Leggetter

MERN, then FERN, and also TERN 😆

Joking aside, super-detailed post 👍

I noticed a few of the code snippets towards from "Connecting the Front-end and Back-end" onwards aren't rendering correctly.

Collapse
 
wra-sol profile image
Nathaniel Arfin

Thank you so much. It was so hard to find that.