PocketBase Authentication in React: A Comprehensive Guide
Francisco Mendes
Posted on February 13, 2023
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
Now we can install the necessary dependencies:
yarn add pocketbase usehooks-ts jwt-decode ms
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";
// ...
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({});
// ...
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), []);
// ...
};
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);
// ...
};
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);
});
}, []);
// ...
};
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 });
}, []);
// ...
};
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);
}, []);
// ...
};
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();
}, []);
// ...
};
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);
// ...
};
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);
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 />;
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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.
Posted on February 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.