DEV Community

Cover image for PocketBase Authentication in React: A Comprehensive Guide
Francisco Mendes
Francisco Mendes

Posted on • Updated on

PocketBase Authentication in React: A Comprehensive Guide

Introduction

In today's article we are going to take advantage of the Software Development Kit provided by PocketBase and we are going to create a Global Context in React to create a new account, log in to an account, log out and refresh the session.

In the same way we are going to create a set of routes and then add a simple protection to ensure that we can access protected routes only when we have a session.

Prerequisites

Before going further, you need:

  • React
  • PocketBase

In addition, you are expected to have basic knowledge of these technologies.

Getting Started

Run the following command in a terminal:

yarn create vite pocket --template react
cd pocket
Enter fullscreen mode Exit fullscreen mode

Now we can install the necessary dependencies:

yarn add pocketbase usehooks-ts jwt-decode ms
Enter fullscreen mode Exit fullscreen mode

That's all we need in today's example, now we need to move on to the next step.

Context Creation

Moving now to the most important part of today's article, let's start by making the necessary imports:

// @/src/contexts/PocketContext.jsx
import {
  createContext,
  useContext,
  useCallback,
  useState,
  useEffect,
  useMemo,
} from "react";
import PocketBase from "pocketbase";
import { useInterval } from "usehooks-ts";
import jwtDecode from "jwt-decode";
import ms from "ms";

// ...
Enter fullscreen mode Exit fullscreen mode

Next, let's create some important variables, such as the base URL of the PocketBase instance and some variables that convert time formats to milliseconds:

// @/src/contexts/PocketContext.jsx
import {
  createContext,
  useContext,
  useCallback,
  useState,
  useEffect,
  useMemo,
} from "react";
import PocketBase from "pocketbase";
import { useInterval } from "usehooks-ts";
import jwtDecode from "jwt-decode";
import ms from "ms";

const BASE_URL = "http://127.0.0.1:8090";
const fiveMinutesInMs = ms("5 minutes");
const twoMinutesInMs = ms("2 minutes");

const PocketContext = createContext({});

// ...
Enter fullscreen mode Exit fullscreen mode

With the necessary variables defined, we can work in context. First of all we have to create our instance of the PocketBase class, I recommend avoiding singletons because in the future it can cause problems and one of the ways we can do it is the following:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

The next step is to define two states, one to manage the token and the other the user object:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  const [token, setToken] = useState(pb.authStore.token);
  const [user, setUser] = useState(pb.authStore.model);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

With the states defined, we can now use the event listener provided by the library to save the user's token and object whenever there is a change in the auth store:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  const [token, setToken] = useState(pb.authStore.token);
  const [user, setUser] = useState(pb.authStore.model);

  useEffect(() => {
    return pb.authStore.onChange((token, model) => {
      setToken(token);
      setUser(model);
    });
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Now we can define some actions, which are related to user authentication. Starting by defining the register function:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  const [token, setToken] = useState(pb.authStore.token);
  const [user, setUser] = useState(pb.authStore.model);

  useEffect(() => {
    return pb.authStore.onChange((token, model) => {
      setToken(token);
      setUser(model);
    });
  }, []);

  const register = useCallback(async (email, password) => {
    return await pb
      .collection("users")
      .create({ email, password, passwordConfirm: password });
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Now moving on to creating the function responsible for user login:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  const [token, setToken] = useState(pb.authStore.token);
  const [user, setUser] = useState(pb.authStore.model);

  useEffect(() => {
    return pb.authStore.onChange((token, model) => {
      setToken(token);
      setUser(model);
    });
  }, []);

  const register = useCallback(async (email, password) => {
    return await pb
      .collection("users")
      .create({ email, password, passwordConfirm: password });
  }, []);

  const login = useCallback(async (email, password) => {
    return await pb.collection("users").authWithPassword(email, password);
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

The logout function will be even simpler, as it only makes changes to local storage, removing the item being persisted locally, as follows:

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  const pb = useMemo(() => new PocketBase(BASE_URL), []);

  const [token, setToken] = useState(pb.authStore.token);
  const [user, setUser] = useState(pb.authStore.model);

  useEffect(() => {
    return pb.authStore.onChange((token, model) => {
      setToken(token);
      setUser(model);
    });
  }, []);

  const register = useCallback(async (email, password) => {
    return await pb
      .collection("users")
      .create({ email, password, passwordConfirm: password });
  }, []);

  const login = useCallback(async (email, password) => {
    return await pb.collection("users").authWithPassword(email, password);
  }, []);

  const logout = useCallback(() => {
    pb.authStore.clear();
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

So far we already have the necessary base to build the most important functionalities of our application. But I still need something extremely important, we need to make it possible to renew the user's session.

The way it will be done is very simple, we will take into account the token that was saved in the state, then we will decode it to obtain the expiration value. Then we add the token expiration value plus five minutes and if the value of the sum is greater than the expiration of the token, we refresh the session. This to ensure that we perform a proactive renewal, without waiting for the unauthorized error (401).

Still taking into account the previous point, let's do the verification above according to a stipulated time interval, taking advantage of the useInterval() hook.

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  // ...

  const refreshSession = useCallback(async () => {
    if (!pb.authStore.isValid) return;
    const decoded = jwtDecode(token);
    const tokenExpiration = decoded.exp;
    const expirationWithBuffer = (decoded.exp + fiveMinutesInMs) / 1000;
    if (tokenExpiration < expirationWithBuffer) {
      await pb.collection("users").authRefresh();
    }
  }, [token]);

  useInterval(refreshSession, token ? twoMinutesInMs : null);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Last but not least, we return the Context with what was defined above and we even create a hook called useAuth() to facilitate access to the Context values.

// @/src/contexts/PocketContext.jsx

// ...

export const PocketProvider = ({ children }) => {
  // ...

  const refreshSession = useCallback(async () => {
    if (!pb.authStore.isValid) return;
    const decoded = jwtDecode(token);
    const tokenExpiration = decoded.exp;
    const expirationWithBuffer = (decoded.exp + fiveMinutesInMs) / 1000;
    if (tokenExpiration < expirationWithBuffer) {
      await pb.collection("users").authRefresh();
    }
  }, [token]);

  useInterval(refreshSession, token ? twoMinutesInMs : null);

  return (
    <PocketContext.Provider
      value={{ register, login, logout, user, token, pb }}
    >
      {children}
    </PocketContext.Provider>
  );
};

export const usePocket = () => useContext(PocketContext);
Enter fullscreen mode Exit fullscreen mode

With all this we can finally move on to the next point.

Route Protection

At this point, we are going to create a React component that will consume the context and, depending on its status, we will redirect the user to the login page if he is not logged in or he will be able to navigate to protected pages.

// @/src/components/RequireAuth.jsx
import { Navigate, Outlet, useLocation } from "react-router-dom";

import { usePocket } from "../contexts/PocketContext";

export const RequireAuth = () => {
  const { user } = usePocket();
  const location = useLocation();

  if (!user) {
    return (
      <Navigate to={{ pathname: "/sign-in" }} state={{ location }} replace />
    );
  }

  return <Outlet />;
};
Enter fullscreen mode Exit fullscreen mode

With the component created, we can now create the page component that will be assigned to a protected route. On this same page we are going to consume the context that was created earlier and we are going to show the object of the user who has the session started and the possibility to end his session.

// @/src/pages/Protected.jsx
import React from "react";

import { usePocket } from "../contexts/PocketContext";

export const Protected = () => {
  const { logout, user } = usePocket();

  return (
    <section>
      <h2>Protected</h2>
      <pre>
        <code>{JSON.stringify(user, null, 2)}</code>
      </pre>
      <button onClick={logout}>Logout</button>
    </section>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now with these two components created we can move on to the next point.

Build the App

At this point we are going to create the remaining pages and we are going to define the application routes. Starting by creating the Sign up page component, let's create a form with uncontrolled inputs (to be simple) and let's use the register() function to create a new account.

// @/src/pages/SignUp.jsx
import React, { useCallback, useRef } from "react";
import { useNavigate, Link } from "react-router-dom";

import { usePocket } from "../contexts/PocketContext";

export const SignUp = () => {
  const emailRef = useRef();
  const passwordRef = useRef();
  const { register } = usePocket();
  const navigate = useNavigate();

  const handleOnSubmit = useCallback(
    async (evt) => {
      evt?.preventDefault();
      await register(emailRef.current.value, passwordRef.current.value);
      navigate("/sign-in");
    },
    [register]
  );

  return (
    <section>
      <h2>Sign Up</h2>
      <form onSubmit={handleOnSubmit}>
        <input placeholder="Email" type="email" ref={emailRef} />
        <input placeholder="Password" type="password" ref={passwordRef} />
        <button type="submit">Create</button>
        <Link to="/sign-in">Go to Sign In</Link>
      </form>
    </section>
  );
};
Enter fullscreen mode Exit fullscreen mode

Similar to the newly created component, we are also going to create the page where we are going to give the user the possibility to start a session through a previously created account, taking advantage of the login() function.

// @/src/pages/SignIn.jsx
import React, { useRef, useCallback } from "react";
import { useNavigate, Link } from "react-router-dom";

import { usePocket } from "../contexts/PocketContext";

export const SignIn = () => {
  const emailRef = useRef();
  const passwordRef = useRef();
  const { login } = usePocket();
  const navigate = useNavigate();

  const handleOnSubmit = useCallback(
    async (evt) => {
      evt?.preventDefault();
      await login(emailRef.current.value, passwordRef.current.value);
      navigate("/protected");
    },
    [login]
  );

  return (
    <section>
      <h2>Sign In</h2>
      <form onSubmit={handleOnSubmit}>
        <input placeholder="Email" type="email" ref={emailRef} />
        <input placeholder="Password" type="password" ref={passwordRef} />
        <button type="submit">Login</button>
        <Link to="/">Go to Sign Up</Link>
      </form>
    </section>
  );
};
Enter fullscreen mode Exit fullscreen mode

With all the necessary components created, we can now define the routes of our application, first we have to import the necessary components and then, by creating the routes, we will also assign their components.

// @/src/App.jsx
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

import { SignIn } from "./pages/SignIn";
import { SignUp } from "./pages/SignUp";
import { Protected } from "./pages/Protected";
import { RequireAuth } from "./components/RequireAuth";

import { PocketProvider } from "./contexts/PocketContext";

export const App = () => {
  return (
    <PocketProvider>
      <BrowserRouter>
        <Routes>
          <Route index element={<SignUp />} />
          <Route path="/sign-in" element={<SignIn />} />
          <Route element={<RequireAuth />}>
            <Route path="/protected" element={<Protected />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </PocketProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

And with that, I conclude today's article.

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (1)

Collapse
 
e63g profile image
e63g

Nice tutorial, great job!
How do you handle errors when doing calls to pocketbase?