Frontend Navigation with React Router

undraw svg icon

Welcome to Part 14 of Up and Running with FastAPI. If you missed part 13, you can find it here.

This series is focused on building a full-stack application with the FastAPI framework. The app allows users to post requests to have their residence cleaned, and other users can select a cleaning project for a given hourly rate.

Up And Running With FastAPI

In the last post, we bootstrapped our React application using create-react-app and built out a navbar and landing page with the help of elastic-ui and styled-components. We also built a custom useCarousel hook that we coupled with framer-motion to power two animations on our landing page.

Now we'll leverage the infrastucture we've developed to implement routing and build out login, sign up, and profile pages. To do all that, we'll bring in the react-router library.

The logical place to start is with routing, so let's do that.

Navigating with React Router

In a traditional application, routing happens on the server. Users change the url and the server provides some html and css that matches the new route. React doesn't work like that. Everything goes down in the browser and requests to the server only happen when the app makes them deliberately. React Router provides a set of navigational components that sync up with the browser's url and handle routing for us.

In the previous post, we installed the history package and react-router-dom@next.

Let's check out our package.json file. What was that @next thing and why did we do it?

Well, react router is in a transition period. There are two primary routing libraries - react router and reach router - both of which are merging into react router 6.0. However, it's not done yet, so at the time this class is being taught, the 6.0.0 version is still in beta.

Personally, I've used lots of react router versions and the current reach router version - and I prefer reach router. I'm happy they're unifying, and I think it's a good direction for the community. However, when you build more react apps in the future, you're going to want to see if any breaking changes have been made to react router. You probably won't have to include that little @next thing either, as v6.0.0 will be fully stable soon enough.

Getting started with react-router is actually really simple. Since we're using the @beta 6.0.0 version, the docs aren't located on their home page. By the time you read this article, it's very possible they'll be front and center on react router's site. Until then, reference this guide. If you do, you'll see that the first step is simply importing the necessary components from react-router-dom and nesting them to declare your routing structure.

Here's how ours will look.

Start by importing a few things from React Router.

App.js
import React from "react"
import { BrowserRouter, Routes, Route } from "react-router-dom"
import { LandingPage, Layout } from "../../components"
export default function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<LandingPage />} />
</Routes>
</Layout>
</BrowserRouter>
)
}

And... tada! We're now rendering the Landing page at our root route. Believe it or not, that's all we need to get routing working in our app. For any new route, we add the component as a child of Routes, which in turn is a child of BrowserRouter somewhere in the tree. Each route has a path and an element prop. The path is the url and the element is the component to render at that url.

What if we were to change the url? If we navigate to something like /nothing, we see the Navbar component and, well, nothing!

This is because our Layout component is rendering children inside the StyledMain component, and rendering the Navbar regardless of whether or not it has children. So any route we navigate to will have the same Navbar component throughout our app, and the element will be rendered as a child of StyledMain.

To see real navigation in action, we'll need to add a few more components.

Go ahead and make components for a login page, a registration page, and a profile page. We'll then make sure to assign each othem their own path. While we're at it, we might as well create a 404 not found page as well.

mkdir src/components/LoginPage src/components/RegistrationPage
mkdir src/components/ProfilePage src/components/NotFoundPage
touch src/components/LoginPage/LoginPage.js src/components/RegistrationPage/RegistrationPage.js
touch src/components/ProfilePage/ProfilePage.js src/components/NotFoundPage/NotFoundPage.js

Then, add some simple markup just so that we can differentiate between each component.

import React from "react"
export default function LoginPage(){
return <h1>Login</h1>
}
import React from "react"
export default function RegistrationPage(){
return <h1>Registration</h1>
}
import React from "react"
export default function ProfilePage(){
return <h1>Profile</h1>
}
import React from "react"
export default function NotFoundPage(){
return <h1>NotFound</h1>
}

We'll export all of them and add them to our router.

components/index.js
export { default as App } from "./App/App"
export { default as Carousel } from "./Carousel/Carousel"
export { default as CarouselTitle } from "./CarouselTitle/CarouselTitle"
export { default as LandingPage } from "./LandingPage/LandingPage"
export { default as Layout } from "./Layout/Layout"
export { default as LoginPage } from "./LoginPage/LoginPage"
export { default as Navbar } from "./Navbar/Navbar"
export { default as NotFoundPage } from "./NotFoundPage/NotFoundPage"
export { default as ProfilePage } from "./ProfilePage/ProfilePage"
export { default as RegistrationPage } from "./RegistrationPage/RegistrationPage"

And back in our App.js component.

App.js
import React from "react"
import { BrowserRouter, Routes, Route } from "react-router-dom"
import { LandingPage, Layout, LoginPage, NotFoundPage, ProfilePage, RegistrationPage } from "../../components"
export default function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/registration" element={<RegistrationPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>
</BrowserRouter>
)
}

Try changing the browser's url now. Any route that matches what we have nested in the Routes component ("/", "/login", "/profile", and "/registration") will render the correct screen. Anything else will match our NotFoundPage component. That's what the wildcard - "*" - path does for us.

Ok, but we don't expect users to type the url they're looking for in the browser each time they want to navigate. Instead, we use a react-router component called Link.

Head into the Navbar component and make the following updates:

Navbar.js
import React from "react"
import {
EuiAvatar,
EuiIcon,
EuiHeader,
EuiHeaderSection,
EuiHeaderSectionItem,
EuiHeaderSectionItemButton,
EuiHeaderLinks,
EuiHeaderLink,
} from "@elastic/eui"
import { Link } from "react-router-dom"
import loginIcon from "../../assets/img/loginIcon.svg"
import styled from "styled-components"
const LogoSection = styled(EuiHeaderLink)`
padding: 0 2rem;
`
export default function Navbar({ user, ...props }) {
return (
<EuiHeader style={props.style || {}}>
<EuiHeaderSection>
<EuiHeaderSectionItem border="right">
<LogoSection href="/">
<EuiIcon type="cloudDrizzle" color="#1E90FF" size="l" /> Phresh
</LogoSection>
</EuiHeaderSectionItem>
<EuiHeaderSectionItem border="right">
<EuiHeaderLinks aria-label="app navigation links">
<EuiHeaderLink iconType="tear" href="#">
Find Cleaners
</EuiHeaderLink>
<EuiHeaderLink iconType="tag" href="#">
Find Jobs
</EuiHeaderLink>
<EuiHeaderLink iconType="help" href="#">
Help
</EuiHeaderLink>
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>
<EuiHeaderSection>
<EuiHeaderSectionItemButton aria-label="User avatar">
{user?.profile ? (
<EuiAvatar size="l" name={user.profile.full_name} imageUrl={user.profile.image} />
) : (
<Link to="/login">
<EuiAvatar size="l" color="#1E90FF" name="user" imageUrl={loginIcon} />
</Link>
)}
</EuiHeaderSectionItemButton>
</EuiHeaderSection>
</EuiHeader>
)
}

Pass the desired path in the to prop of the Link component and it will programmatically navigate to the intended location on click, just like a normal link.

Now, if we go to our landing page and click the login icon, we're taken to the login page. That's exactly what we want.

Check it out on Code Sandbox

phresh-frontend-part-2-react-router

Time to create our auth UI.

Setting Up A Login Form

We won't do anything too wild for our login page. Users should be able to enter in their email and password, click submit, and authentication. We'll also provide a link to navigate to the registration page if need be.

Update the LoginPage with the following code:

LoginPage.js
import React from "react"
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
} from "@elastic/eui"
import styled from "styled-components"
const StyledEuiPage = styled(EuiPage)`
flex: 1;
`
const StyledEuiPageHeader = styled(EuiPageHeader)`
display: flex;
justify-content: center;
align-items: center;
margin: 2rem;
& h1 {
font-size: 3.5rem;
}
`
export default function LoginPage() {
return (
<StyledEuiPage>
<EuiPageBody component="section">
<StyledEuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Login</h1>
</EuiTitle>
</EuiPageHeaderSection>
</StyledEuiPageHeader>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiPageContentBody>
<>
{/* FORM GOES HERE */}
</>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</StyledEuiPage>
)
}

Now let's build a LoginForm component that will manage user inputs. It's a good idea to read the form guidelines that elastic-ui provides here, as they do a good job of outlining the thinking behind how we'll structure form components.

Then go ahead and create the new component.

mkdir src/components/LoginForm/
touch src/components/LoginForm/LoginForm.js

Add a simple component skeleton:

LoginForm.js
import React from "react"
import styled from "styled-components"
const LoginFormWrapper = styled.div``
export default function LoginForm(props) {
return (
<LoginFormWrapper>
<p>Login here</p>
</LoginFormWrapper>
)
}

Export it from the components/index.js file:

components/index.js
export { default as App } from "./App/App"
export { default as Carousel } from "./Carousel/Carousel"
export { default as CarouselTitle } from "./CarouselTitle/CarouselTitle"
export { default as LandingPage } from "./LandingPage/LandingPage"
export { default as Layout } from "./Layout/Layout"
export { default as LoginForm } from "./LoginForm/LoginForm"
export { default as LoginPage } from "./LoginPage/LoginPage"
export { default as Navbar } from "./Navbar/Navbar"
export { default as NotFoundPage } from "./NotFoundPage/NotFoundPage"
export { default as ProfilePage } from "./ProfilePage/ProfilePage"
export { default as RegistrationPage } from "./RegistrationPage/RegistrationPage"

And add it to our LoginPage component:

LoginPage.js
import React from "react"
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
} from "@elastic/eui"
import { LoginForm } from "../../components"
import styled from "styled-components"
const StyledEuiPage = styled(EuiPage)`
flex: 1;
`
const StyledEuiPageHeader = styled(EuiPageHeader)`
display: flex;
justify-content: center;
align-items: center;
margin: 2rem;
& h1 {
font-size: 3.5rem;
}
`
export default function LoginPage() {
return (
<StyledEuiPage>
<EuiPageBody component="section">
<StyledEuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Login</h1>
</EuiTitle>
</EuiPageHeaderSection>
</StyledEuiPageHeader>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiPageContentBody>
<LoginForm />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</StyledEuiPage>
)
}

Ok, we have soemthing on the screen. Lovely.

Now let's add some elastic-ui form elements to our LoginForm component.

LoginForm.js
import React from "react"
import {
EuiButton,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldPassword,
EuiSpacer
} from "@elastic/eui"
import { Link } from "react-router-dom"
import styled from "styled-components"
const LoginFormWrapper = styled.div`
padding: 2rem;
`
const NeedAccountLink = styled.span`
font-size: 0.8rem;
`
export default function LoginForm({
requestUserLogin = async ({ email, password }) =>
console.log(`Logging in with ${email} and ${password}.`)
}) {
const [form, setForm] = React.useState({
email: "",
password: ""
})
const handleInputChange = (label, value) => {
setForm((form) => ({ ...form, [label]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
await requestUserLogin({ email: form.email, password: form.password })
}
return (
<LoginFormWrapper>
<EuiForm component="form" onSubmit={handleSubmit}>
<EuiFormRow label="Email" helpText="Enter the email associated with your account.">
<EuiFieldText
icon="email"
placeholder="user@gmail.com"
value={form.email}
onChange={(e) => handleInputChange("email", e.target.value)}
aria-label="Enter the email associated with your account."
/>
</EuiFormRow>
<EuiFormRow label="Password" helpText="Enter your password.">
<EuiFieldPassword
placeholder="••••••••••••"
value={form.password}
onChange={(e) => handleInputChange("password", e.target.value)}
type="dual"
aria-label="Enter your password."
/>
</EuiFormRow>
<EuiSpacer />
<EuiButton type="submit" fill>
Submit
</EuiButton>
</EuiForm>
<EuiSpacer size="xl" />
<NeedAccountLink>
Need an account? Sign up <Link to="/registration">here</Link>.
</NeedAccountLink>
</LoginFormWrapper>
)
}

Quite a few things going on here, so let's break it down.

  • The EuiForm component groups EuiFormRows into multiple sections.
  • The EuiForm component renders as a div by default, but use component="form" to change that. We also pass it an onSubmit handler that will trigger when the user submits the form.
  • Each EuiFormRow component associates form components with labels, help text, and error text. We pass a label and helpText for both the email and password rows.
  • We use an EuiTextField component to render the email input and a EuiFieldPassword component to render the password input.
  • The state of both inputs is stored locally and managed through a single handleInputChange function.
  • Items are spaced out vertically using the EuiSpacer component and the form is submitted using an EuiButton component.
  • At the bottom, we place a link to the /registration route in case the user needs to sign up.

This actually works pretty well, though the form could benefit from a few improvements.

Try hitting the submit button and check the console. We see a nice message telling us the contents of the inputs when submitted.

Notice how the user is able to submit the form with nothing entered in either input field? We should prevent that. There's also no client-side validation for properly-formatted emails or appropriate password length.

Fortunately, elastic-ui makes that rather trivial.

We'll start by creating a new file in the utils repo called validation.js.

touch src/utils/validation.js

Then we'll add two new validation functions for email and password inputs.

utils/validation.js
/**
* VERY simple email validation
*
* @param {String} text - email to be validated
* @return {Boolean}
*/
export function validateEmail(text) {
return text?.indexOf("@") !== -1
}
/**
* Ensures password is at least a certain length
*
* @param {String} password - password to be validated
* @param {Integer} length - length password must be as long as
* @return {Boolean}
*/
export function validatePassword(password, length = 7) {
return password?.length >= length
}
export default {
email: validateEmail,
password: validatePassword,
}

We export them as an object that matches the name of their input field. This will make it easy to do validation whenever the input value changes.

Let's see that in action.

LoginForm.js
import React from "react"
import {
EuiButton,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldPassword,
EuiSpacer
} from "@elastic/eui"
import { Link } from "react-router-dom"
import validation from "../../utils/validation"
import styled from "styled-components"
const LoginFormWrapper = styled.div`
padding: 2rem;
`
const NeedAccountLink = styled.span`
font-size: 0.8rem;
`
export default function LoginForm({
requestUserLogin = async ({ email, password }) =>
console.log(`Logging in with ${email} and ${password}.`)
}) {
const [form, setForm] = React.useState({
email: "",
password: ""
})
const [errors, setErrors] = React.useState({})
const validateInput = (label, value) => {
// grab validation function and run it on input if it exists
// if it doesn't exists, just assume the input is valid
const isValid = validation?.[label] ? validation?.[label]?.(value) : true
// set an error if the validation function did NOT return true
setErrors((errors) => ({ ...errors, [label]: !isValid }))
}
const handleInputChange = (label, value) => {
validateInput(label, value)
setForm((form) => ({ ...form, [label]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
// validate inputs before submitting
Object.keys(form).forEach((label) => validateInput(label, form[label]))
// if any input hasn't been entered in, return early
if (!Object.values(form).every((value) => Boolean(value))) {
setErrors((errors) => ({ ...errors, form: `You must fill out all fields.` }))
return
}
await requestUserLogin({ email: form.email, password: form.password })
}
return (
<LoginFormWrapper>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(errors.form)}
error={errors.form}
>
<EuiFormRow
label="Email"
helpText="Enter the email associated with your account."
isInvalid={Boolean(errors.email)}
error={`Please enter a valid email.`}
>
<EuiFieldText
icon="email"
placeholder="user@gmail.com"
value={form.email}
onChange={(e) => handleInputChange("email", e.target.value)}
aria-label="Enter the email associated with your account."
isInvalid={Boolean(errors.email)}
/>
</EuiFormRow>
<EuiFormRow
label="Password"
helpText="Enter your password."
isInvalid={Boolean(errors.password)}
error={`Password must be at least 7 characters.`}
>
<EuiFieldPassword
placeholder="••••••••••••"
value={form.password}
onChange={(e) => handleInputChange("password", e.target.value)}
type="dual"
aria-label="Enter your password."
isInvalid={Boolean(errors.password)}
/>
</EuiFormRow>
<EuiSpacer />
<EuiButton type="submit" fill>
Submit
</EuiButton>
</EuiForm>
<EuiSpacer size="xl" />
<NeedAccountLink>
Need an account? Sign up <Link to="/registration">here</Link>.
</NeedAccountLink>
</LoginFormWrapper>
)
}

First, we import our validation object at the top of the LoginForm component. We also add an errors object to our state. Then we update our handleInputChange function so that the input label is used to access the appropriate validation function, and call it on the input value. If the validation function returns false, meaning the input is not valid, then we set an error in state.

By the way, some readers may be unfamiliar with the optional chaining operator, like the one seen in validation?.[label]. It works identically to traditional object value access except that if any value in the chain doesn't exist, it shortcuts to undefined. It prevents a lot of obj && obj.val && obj.val.method() code and is my preferred way to access nested objects.

Our EuiForm and both EuiFormRow components accept an isInvalid prop that indicates an error exists. We pass that isInvalid prop the appropriate form state, cast into a boolean, along with an error prop - which is the actual error text to be displayed. We also provide the isInvalid prop to the EuiFieldText and EuiFieldPassword components for maximum user feedback.

Try submitting with empty fields now. See that nice invalid popup? Try submitting an invalid email or a password that is too short. Nothing is logged to the console and we continue to get the invalid popup. That's a fantastic feature that comes baked into elastic-ui. There's more that can be done here, so it's recommended to check out their validation docs.

The result of our work is a clean, intuitive, high-quality login form that we'll steal from any time we want to create a new form.

Check it out on Code Sandbox

phresh-frontend-part-2-creating-a-login-form-with-elastic-ui

Looking good! Onto the registration page.

Creating the Signup Page

The nice part about doing all that hard work with the LoginForm is that creating a RegistrationForm is easy. We'll take advantage of all the code we've already written, since some of it will be identical. As an exercise for the reader, try to implement a RegistrationForm component independently, using the same techniques that we just went over.

Start off by copying the same layout from LoginPage into RegistrationPage and replace the LoginForm with a newly created RegistrationForm.js file.

Then update the RegistrationForm with the required fields. Make sure to include a username field, a passwordConfirm field, and a checkbox indicating the user has agreed to our terms and conditions. Oh, and an additional validation function for the username field! Don't be afraid to reference the elastic-ui docs for these tasks.

When finished, return here for a straightforward solution.

... done?

Great. On to the code.

Designing a Registration Form

As always, create the new component.

mkdir src/components/RegistrationForm
touch src/components/RegistrationForm/RegistrationForm.js

Let's also makes sure to export that file - even though it's empty.

components/index.js
export { default as App } from "./App/App"
export { default as Carousel } from "./Carousel/Carousel"
export { default as CarouselTitle } from "./CarouselTitle/CarouselTitle"
export { default as LandingPage } from "./LandingPage/LandingPage"
export { default as Layout } from "./Layout/Layout"
export { default as LoginForm } from "./LoginForm/LoginForm"
export { default as LoginPage } from "./LoginPage/LoginPage"
export { default as Navbar } from "./Navbar/Navbar"
export { default as NotFoundPage } from "./NotFoundPage/NotFoundPage"
export { default as ProfilePage } from "./ProfilePage/ProfilePage"
export { default as RegistrationForm } from "./RegistrationForm/RegistrationForm"
export { default as RegistrationPage } from "./RegistrationPage/RegistrationPage"

Go ahead and update the RegistrationPage with code stolen directly from the LoginForm component.

RegistrationPage.js
import React from "react"
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle
} from "@elastic/eui"
import { RegistrationForm } from "../../components"
import styled from "styled-components"
const StyledEuiPage = styled(EuiPage)`
flex: 1;
`
const StyledEuiPageHeader = styled(EuiPageHeader)`
display: flex;
justify-content: center;
align-items: center;
margin: 2rem;
& h1 {
font-size: 3.5rem;
}
`
export default function RegistrationPage() {
return (
<StyledEuiPage>
<EuiPageBody component="section">
<StyledEuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Sign Up</h1>
</EuiTitle>
</EuiPageHeaderSection>
</StyledEuiPageHeader>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiPageContentBody>
<RegistrationForm />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</StyledEuiPage>
)
}

Nothing new here. Same pattern as before.

We're ready to take a look at our solution. Building the RegistrationForm is half copy/paste, half elastic-ui docs - and that is fine with me.

Here's what we have in the new file:

RegistrationForm.js
import React from "react"
import {
EuiButton,
EuiCheckbox,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldPassword,
EuiSpacer
} from "@elastic/eui"
import { Link } from "react-router-dom"
import validation from "../../utils/validation"
import { htmlIdGenerator } from "@elastic/eui/lib/services"
import styled from "styled-components"
const RegistrationFormWrapper = styled.div`
padding: 2rem;
`
const NeedAccountLink = styled.span`
font-size: 0.8rem;
`
export default function RegistrationForm({
registerUser = async ({ username, email, password }) =>
console.log(`Signing up with ${username}, ${email}, and ${password}.`)
}) {
const [form, setForm] = React.useState({
username: "",
email: "",
password: "",
passwordConfirm: ""
})
const [agreedToTerms, setAgreedToTerms] = React.useState(false)
const [errors, setErrors] = React.useState({})
const validateInput = (label, value) => {
// grab validation function and run it on input if it exists
// if it doesn't exists, just assume the input is valid
const isValid = validation?.[label] ? validation?.[label]?.(value) : true
// set an error if the validation function did NOT return true
setErrors((errors) => ({ ...errors, [label]: !isValid }))
}
const setAgreedToTermsCheckbox = (e) => {
setAgreedToTerms(e.target.checked)
}
const handleInputChange = (label, value) => {
validateInput(label, value)
setForm((form) => ({ ...form, [label]: value }))
}
const handlePasswordConfirmChange = (value) => {
setErrors((errors) => ({
...errors,
passwordConfirm: form.password !== value ? `Passwords do not match.` : null
}))
setForm((form) => ({ ...form, passwordConfirm: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
// validate inputs before submitting
Object.keys(form).forEach((label) => validateInput(label, form[label]))
// if any input hasn't been entered in, return early
if (!Object.values(form).every((value) => Boolean(value))) {
setErrors((errors) => ({ ...errors, form: `You must fill out all fields.` }))
return
}
// some additional validation
if (form.password !== form.passwordConfirm) {
setErrors((errors) => ({ ...errors, form: `Passwords do not match.` }))
return
}
if (!agreedToTerms) {
setErrors((errors) => ({ ...errors, form: `You must agree to the terms and conditions.` }))
return
}
await registerUser({ username: form.username, email: form.email, password: form.password })
}
return (
<RegistrationFormWrapper>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(errors.form)}
error={[errors.form]}
>
<EuiFormRow
label="Email"
helpText="Enter the email associated with your account."
isInvalid={Boolean(errors.email)}
error={`Please enter a valid email.`}
>
<EuiFieldText
icon="email"
placeholder="user@gmail.com"
value={form.email}
onChange={(e) => handleInputChange("email", e.target.value)}
aria-label="Enter the email associated with your account."
isInvalid={Boolean(errors.email)}
/>
</EuiFormRow>
<EuiFormRow
label="Username"
helpText="Choose a username consisting solely of letters, numbers, underscores, and dashes."
isInvalid={Boolean(errors.username)}
error={`Please enter a valid username.`}
>
<EuiFieldText
icon="user"
placeholder="your_username"
value={form.username}
onChange={(e) => handleInputChange("username", e.target.value)}
aria-label="Choose a username consisting of letters, numbers, underscores, and dashes."
isInvalid={Boolean(errors.username)}
/>
</EuiFormRow>
<EuiFormRow
label="Password"
helpText="Enter your password."
isInvalid={Boolean(errors.password)}
error={`Password must be at least 7 characters.`}
>
<EuiFieldPassword
placeholder="••••••••••••"
value={form.password}
onChange={(e) => handleInputChange("password", e.target.value)}
type="dual"
aria-label="Enter your password."
isInvalid={Boolean(errors.password)}
/>
</EuiFormRow>
<EuiFormRow
label="Confirm password"
helpText="Confirm your password."
isInvalid={Boolean(errors.passwordConfirm)}
error={`Passwords must match.`}
>
<EuiFieldPassword
placeholder="••••••••••••"
value={form.passwordConfirm}
onChange={(e) => handlePasswordConfirmChange(e.target.value)}
type="dual"
aria-label="Confirm your password."
isInvalid={Boolean(errors.passwordConfirm)}
/>
</EuiFormRow>
<EuiSpacer />
<EuiCheckbox
id={htmlIdGenerator()()}
label="I agree to the terms and conditions."
checked={agreedToTerms}
onChange={(e) => setAgreedToTermsCheckbox(e)}
/>
<EuiSpacer />
<EuiButton type="submit" fill>
Sign Up
</EuiButton>
</EuiForm>
<EuiSpacer size="xl" />
<NeedAccountLink>
Already have an account? Log in <Link to="/login">here</Link>.
</NeedAccountLink>
</RegistrationFormWrapper>
)
}

Not terrible, right? The big new additions are as follows:

  • New username text field and passwordConfirm password field inputs
  • Behind the scenes, the username field is validated with a yet-to-be-written validation function.
  • We manage changing the passwordConfirm input differently so that we can validate it against another form field - the password field. We ensure that both are identical when changing inputs, and when submitting the form.
  • For our terms and conditions input, we use an EuiCheckbox with a required id prop that is created using the htmlIdGenerator generator from elastic-ui services.
  • We only validate that the terms and conditions have been checked when the user submits, and show an error if they haven't.

All we really need to do now is add our new validation function.

utils/validation.js
/**
* VERY simple email validation
*
* @param {String} text - email to be validated
* @return {Boolean}
*/
export function validateEmail(text) {
return text?.indexOf("@") !== -1
}
/**
* Ensures password is of at least a certain length
*
* @param {String} password - password to be validated
* @param {Integer} length - length password must be as long as
* @return {Boolean}
*/
export function validatePassword(password, length = 7) {
return password?.length >= length
}
/**
* Ensures a username consists of only letters, numbers, underscores, and dashes
*
* @param {String} username - username to be validated
* @return {Boolean}
*/
export function validateUsername(username) {
return /^[a-zA-Z0-9_-]+$/.test(username)
}
export default {
email: validateEmail,
password: validatePassword,
username: validateUsername
}

We copy the regex that our pydantic user models employ, and test the username text against it. Once we export that function as the username property, it can be consumed properly by our RegistrationForm component.

Everything else is pretty much the same, so we won't touch on it too much here.

Check it out on Code Sandbox

phresh-frontend-part-2-adding-an-elastic-ui-registration-page

Once the user is authenticated, they should be able to navigate to their own profile page.

Since it probably doesn't make sense to do that until we've implemented an actual authentication mechanism, let's hold off until the next post.

Wrapping Up and Resources

We've set ourselves up nicely here. Our frontend has pages for logging in and signing up, and all we need to do is wire them up to our backend. That's what we'll do next time, and we'll bring in the redux and axios libraries to make it happen.

  • React Router V6 outline
  • MDN Optional Chaining reference
  • Elastic UI Form Layout docs
  • Elastic UI Form Controls docs
  • ElastiC UI Form Validation docs
  • Elastic UI htmlIdGenerator docs
  • Elastic UI Button docs
  • Elastic UI Spacer docs