Refactoring Our React UI Into Composable Hooks

undraw svg icon

Welcome to Part 23 of Up and Running with FastAPI. If you missed part 22, 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

Last time we built out a CleaningActivityFeed component along with a custom useCleaningFeed hook that implemented all of the redux and async logic needed to fetch paginated lists of cleaning events and display them as a feed for authenticated users.

In this post, we'll briefly pause any new feature development and focus on refactoring our ever expanding UI. The intent is to centralize much of our frontend logic into composable hooks that will help make our components smaller and more manageable.

Adding a UI Redux Slice

We're going to be making quite a few updates in this post, so before we get into our first refactor, let's get a few quick wins in the books. At the moment, we have some disparate logic related to protected routes and authentication that should probably be centralized.

If we look at our ProtectedRoute component, we see that it's checking for an authenticated user and rendering LoginPage and EuiGlobalToastList components with a custom toast (stored in React local state) if no user is currently logged in. Then in our App component, we're using the ProtectedRoute component a couple times to ensure that only logged in users can access certain pages.

This doesn't smell right. The word "global" in EuiGlobalToastList most likely indicates that it should only exist once in our application. And using local state for toasts doesn't feel right either. What if we want to create success toasts? Or info toasts? We'll need to rework this one.

On top of that, we're passing auth information into our ProtectedRoute component through redux's higher order component, connect. We do that all over our application. Maybe it's time to think about a better way to handle that as well.

Let's do this in small chunks, refactoring where we need to. Many things will be left in place until we have a full rework ready for them.

First up, we'll create a ui redux slice to handle all global UI state.

Add a new section to our redux/initialState.js file:

redux/initialState.js
export default {
auth: {
isLoading: false,
isUpdating: false,
isAuthenticated: false,
error: null,
userLoaded: false,
user: {},
},
cleanings: {
isLoading: false,
isUpdating: false,
error: null,
data: {},
currentCleaningJob: null,
},
offers: {
isLoading: false,
isUpdating: false,
error: null,
data: {},
},
feed: {
isLoading: false,
error: null,
data: {},
hasNext: {},
},
ui: {
toastList: [],
},
}

And then create a new ui.js file in the redux directory.

touch src/redux/ui.js

All we'll add to this file to begin with will be some logic to handle adding and removing toasts from redux.

redux/ui.js
import initialState from "redux/initialState"
export const ADD_TOAST = "@@ui/ADD_TOAST"
export const REMOVE_TOAST = "@@ui/REMOVE_TOAST"
export default function uiReducer(state = initialState.ui, action = {}) {
switch (action.type) {
case ADD_TOAST:
return {
...state,
toastList: [...state.toastList, action.toast],
}
case REMOVE_TOAST:
return {
...state,
toastList: state.toastList.filter((toast) => toast.id !== action.toastId),
}
default:
return state
}
}
export const Actions = {}
Actions.addToast = (toast) => {
return (dispatch, getState) => {
const { ui } = getState()
const toastIds = ui.toastList.map((toast) => toast.id)
if (toastIds.indexOf(toast.id) === -1) {
dispatch({ type: ADD_TOAST, toast })
}
}
}
Actions.removeToast = (toast) => {
return dispatch => {
dispatch({ type: REMOVE_TOAST, toastId: toast.id })
}
}

We create two new actions for adding and removing toasts from redux, a new reducer for managing the toastList array we're tracking, and two action creator functions responsible for dispatching our actions. In the addToast action creator, we also dedupe any toasts - in case we accidentally added multiple versions of the same toast.

That wasn't so bad!

Make sure to add the new slice to our root reducer:

redux/rootReducer.js
import { combineReducers } from "redux"
import authReducer from "./auth"
import cleaningsReducer from "./cleanings"
import offersReducer from "./offers"
import feedReducer from "./feed"
import uiReducer from "./ui"
const rootReducer = combineReducers({
auth: authReducer,
cleanings: cleaningsReducer,
offers: offersReducer,
feed: feedReducer,
ui: uiReducer,
})
export default rootReducer

Now let's create a custom hook to manage our toasts logic. Since we're going to be adding quite a few hooks this post, we'll want to organize our directory more effectively. Create a ui directory inside the hooks directory, and add a useToasts.js file to that new directory. Let's also move the useCarousel hook into that directory, since it falls under the ui category.

mkdir src/hooks/ui
touch src/hooks/ui/useToasts.js
mv src/hooks/useCarousel.js src/hooks/ui/

Now that breaks our LandingPage component's imports, so we'll go fix that and migrate to absolute imports while we're at it.

LandingPage.js
import React from "react"
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiFlexGroup,
EuiFlexItem
} from "@elastic/eui"
import { Carousel, CarouselTitle } from "components"
import { useCarousel } from "hooks/ui/useCarousel"
import dorm from "assets/img/Bed.svg"
import bedroom from "assets/img/Bedroom.svg"
import bathroom from "assets/img/Bathroom.svg"
import heroGirl from "assets/img/HeroGirl.svg"
import livingRoom from "assets/img/Living_room_interior.svg"
import kitchen from "assets/img/Kitchen.svg"
import readingRoom from "assets/img/Reading_room.svg"
import tvRoom from "assets/img/TV_room.svg"
import styled from "styled-components"
// ...other code

That'll do.

In the useToasts.js file, add the following:

hooks/ui/useToasts.js
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { Actions as uiActions } from "redux/ui"
export const useToasts = () => {
const dispatch = useDispatch()
const toasts = useSelector((state) => state.ui.toastList, shallowEqual)
const addToast = (toast) => dispatch(uiActions.addToast(toast))
const removeToast = (toastId) => dispatch(uiActions.removeToast(toastId))
return { toasts, addToast, removeToast }
}

Ok. Here we have a custom hook file that's barely longer than 10 lines. It grabs a reference to the toastList stored in redux, and creates two wrapper functions that dispatch the associated action creator functions. Our hook then returns all three.

With that taken care of, let's add the EuiGlobalToastList component to the Layout component, so that it will actually be a global component in our application. In this file - as in all others we touch in this post - we'll also migrate to absolute imports.

Layout.js
import React from "react"
import { Helmet } from "react-helmet"
import { Navbar } from "components"
import styled, { ThemeProvider } from "styled-components"
import euiVars from "@elastic/eui/dist/eui_theme_light.json"
import { EuiGlobalToastList } from "@elastic/eui"
import { useToasts } from "hooks/ui/useToasts"
import "assets/css/fonts.css"
import "assets/css/overrides.css"
// ...other code
export default function Layout({ children }) {
const { toasts, removeToast } = useToasts()
return (
<React.Fragment>
<Helmet>
<meta charSet="utf-8" />
<title>Phresh Cleaners</title>
<link rel="canonical" href="https://phreshcleaners.com" />
</Helmet>
<ThemeProvider theme={customTheme}>
<StyledLayout>
<Navbar />
<StyledMain>{children}</StyledMain>
<EuiGlobalToastList
toasts={toasts}
dismissToast={(toastId) => removeToast(toastId)}
toastLifeTimeMs={15000}
side="right"
className="auth-toast-list"
/>
</StyledLayout>
</ThemeProvider>
</React.Fragment>
)
}

No need to import redux, no need to add additional props. We destruture toasts and removeToast from the object returned by our useToasts hook, and then pass the needed values to the EuiGlobalToastList component.

Lovely.

We can now head into our ProtectedRoute component and make similar updates.

ProtectedRoute.js
import React from "react"
import { EuiLoadingSpinner } from "@elastic/eui"
import { useToasts } from "hooks/ui/useToasts"
import { LoginPage } from "components"
import { connect } from "react-redux"
function ProtectedRoute({
user,
userLoaded,
isAuthenticated,
component: Component,
redirectTitle = `Access Denied`,
redirectMessage = `Authenticated users only. Login here or create a new account to view that page.`,
...props
}) {
const { addToast } = useToasts()
const isAuthed = isAuthenticated && Boolean(user?.email)
React.useEffect(() => {
if (userLoaded && !isAuthed) {
addToast({
id: `auth-toast-redirect`,
title: redirectTitle,
color: "warning",
iconType: "alert",
toastLifeTimeMs: 15000,
text: redirectMessage,
})
}
}, [userLoaded, isAuthed, addToast, redirectTitle, redirectMessage])
if (!userLoaded) return <EuiLoadingSpinner size="xl" />
if (!isAuthed) {
return (
<>
<LoginPage />
</>
)
}
return <Component {...props} />
}
export default connect((state) => ({
user: state.auth.user,
isAuthenticated: state.auth.isAuthenticated,
userLoaded: state.auth.userLoaded,
}))(ProtectedRoute)

Some improvements here. We've extracted the toasts logic out and that's nice. But there's still quite a bit of auth logic that could be centralized as well. Let's do that next.

The useAuthenticatedUser hook

We're injecting authenticated user state into multiple places across our app. Standardizing that process is going to make our lives easier, so that's the first item on our agenda. Afterwards, we'll use that hook to create a useProtectedRoute hook to handle most of what's going on in our ProtectedRoute component.

Create a new directory called auth in the hooks directory, and add a useAuthenticatedUser.js file to it.

mkdir src/hooks/auth
touch src/hooks/auth/useAuthenticatedUser.js

Now go ahead and add the following code to our new file.

useAuthenticatedUser.js
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { Actions as authActions } from "redux/auth"
export const useAuthenticatedUser = () => {
const dispatch = useDispatch()
const error = useSelector((state) => state.auth.error)
const isLoading = useSelector((state) => state.auth.isLoading)
const isUpdating = useSelector((state) => state.auth.isUpdating)
const userLoaded = useSelector((state) => state.auth.userLoaded)
const isAuthenticated = useSelector((state) => state.auth.isAuthenticated)
const user = useSelector((state) => state.auth.user, shallowEqual)
const logUserOut = () => dispatch(authActions.logUserOut())
return { userLoaded, isLoading, isUpdating, error, isAuthenticated, user, logUserOut }
}

Looking good. We access any auth errors, a few status booleans, and the authenticated user - if it exists. We also add the logUserOut function for simplicity. Finally, we return an object with everything we've just added, and we're good to go.

So, where can we use this new hook? Well, in a lot of places. Start with the Navbar and make the following changes:

Navbar.js
import React from "react"
import { useAuthenticatedUser } from "hooks/auth/useAuthenticatedUser"
import {
EuiAvatar,
EuiIcon,
EuiHeader,
EuiHeaderSection,
EuiHeaderSectionItem,
EuiHeaderSectionItemButton,
EuiHeaderLinks,
EuiHeaderLink,
EuiPopover,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
} from "@elastic/eui"
import { UserAvatar } from "components"
import { Link, useNavigate } from "react-router-dom"
import loginIcon from "assets/img/loginIcon.svg"
import styled from "styled-components"
// ..other code
export default function Navbar() {
const navigate = useNavigate()
const [avatarMenuOpen, setAvatarMenuOpen] = React.useState(false)
const { user, logUserOut } = useAuthenticatedUser()
// ...other code
const avatarButton = (
<EuiHeaderSectionItemButton
aria-label="User avatar"
onClick={() => user?.profile && toggleAvatarMenu()}
>
{user?.profile ? (
<UserAvatar size="l" user={user} initialsLength={2} />
) : (
<Link to="/login">
<EuiAvatar size="l" color="#1E90FF" name="user" imageUrl={loginIcon} />
</Link>
)}
</EuiHeaderSectionItemButton>
)
const renderAvatarMenu = () => {
if (!user?.profile) return null
return (
<AvatarMenu>
<UserAvatar size="xl" user={user} initialsLength={2} />
<EuiFlexGroup direction="column" className="avatar-actions">
<EuiFlexItem grow={1}>
<p>
{user.email} - {user.username}
</p>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={1}>
<Link to="/profile">Profile</Link>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiLink onClick={() => handleLogout()}>Log out</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</AvatarMenu>
)
}
// ...other code
}

That was easy enough. We remove any references to redux, no longer need to connect the component, and can simply default export the Navbar function. We've also removed any props the component takes in, so everything is defined directly in the component. Additionally, we've moved over to using the UserAvatar component we defined in the last post to help standardize our use of avatars.

Moving on the ProfilePage component, let's do the same thing.

ProfilePage.js
import React from "react"
import { useAuthenticatedUser } from "hooks/auth/useAuthenticatedUser"
import {
EuiHorizontalRule,
EuiIcon,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiText,
} from "@elastic/eui"
import { UserAvatar } from "components"
import moment from "moment"
import styled from "styled-components"
// ...other code
export default function ProfilePage() {
const { user } = useAuthenticatedUser()
return (
<StyledEuiPage>
<EuiPageBody component="section">
<StyledEuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Profile</h1>
</EuiTitle>
</EuiPageHeaderSection>
</StyledEuiPageHeader>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<StyledEuiPageContentBody>
<UserAvatar size="xl" user={user} initialsLength={2} />
{/* ...other code */}
</StyledEuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</StyledEuiPage>
)
}

A lot of the same going on here. All that repetition dried up! Excellent.

This feels pretty good, but we're only just getting to the cool part.

We're going to make use of our new hooks, inside of another hook! Hook-ception.

Create another file in the hooks/auth directory called useProtectedRoute.js.

touch src/hooks/auth/useProtectedRoute.js

Inside that new file, add the following:

useProtectedRoute.js
import { useEffect } from "react"
import { useAuthenticatedUser } from "hooks/auth/useAuthenticatedUser"
import { useToasts } from "hooks/ui/useToasts"
export const useProtectedRoute = (
redirectTitle = `Access Denied`,
redirectMessage = `Authenticated users only. Login here or create a new account to view that page.`
) => {
const { userLoaded, isAuthenticated } = useAuthenticatedUser()
const { addToast } = useToasts()
useEffect(() => {
if (userLoaded && !isAuthenticated) {
addToast({
id: `auth-toast-redirect`,
title: redirectTitle,
color: "warning",
iconType: "alert",
toastLifeTimeMs: 15000,
text: redirectMessage,
})
}
}, [userLoaded, isAuthenticated, redirectTitle, redirectMessage, addToast])
return { userLoaded, isAuthenticated }
}

Woah woah woah. This useProtectedRoute hook takes advantage of not one, but two external hooks to implement the appropriate logic. And it makes sense too. But it'll make more sense when we actually see what the new ProtectedRoute component looks like.

Let's update that now.

useProtectedRoute.js
import React from "react"
import { useProtectedRoute } from "hooks/auth/useProtectedRoute"
import { EuiLoadingSpinner } from "@elastic/eui"
import { LoginPage } from "components"
export default function ProtectedRoute({ component: Component, ...props }) {
const { isAuthenticated, userLoaded } = useProtectedRoute()
if (!userLoaded) return <EuiLoadingSpinner size="xl" />
if (!isAuthenticated) {
return (
<>
<LoginPage />
</>
)
}
return <Component {...props} />
}

And poof! Most of this component has disappeared. Or more appropriately, been abstracted into our useProtectedRoute hook. It gets the authenticated user, makes sure we've attempted to authenticate them already, and if no user is logged in, adds an "Access Denied" toast to our global toasts array. We then pass along the isAuthenticated and userLoaded flags which can be used in our ProtectedRoute component to render the proper display for authed and unauthed users.

Here's a codesandbox with all of the associated code we've written up to this point. Check to make sure things match.

Check it out on Code Sandbox

phresh-frontend-part-10-refactoring-our-UI-with-custom-hooks

Our code is starting to feel quite a bit cleaner and easier to reason about. There's one more set of changes that will offers marked improvements we should make before wrapping up for the day.

We're going to create a useLoginAndRegistrationForm hook to abstract away much of the UI work that's happening in our LoginForm and RegistrationForm components. There's a lot of repetition between those two files as well, so it'll feel nice if we can write a custom hook that'll work for both.

the useLoginAndRegistrationform hook

Since the headline gives away our new hook's name, there's no need to introduce it.

Now, there are probably a few readers who might be thinking, "The name of that hook is way too long". And they would be right. Maybe it should be called useAuthenticationForm or something. However, this isn't a post about naming variables and hooks, so we'll leave it what it is. At least the name explicitly describes its intended behavior.

So, what goes in it?

First, create the new file.

touch src/hooks/ui/useLoginAndRegistrationForm.js

We'll be extracting all of the core logic shared by our LoginForm and RegistrationForm components - minus the submission part - into this new hook. Anything submission-related, we'll let the components handle.

Just note, we're going to throw down a bunch of code here, then break apart what we're accomplishing. First, it would probably make sense to go ahead and take a look at both the LoginForm.js and RegistrationForm.js components.

Notice the overlap between the two? That's mainly what we'll be looking to remedy. At the same time, we're going to remove most of the non-markup-related code, as our hook will be handling that part for us.

This refactor will make our form components smaller and less obtuse. It will also set the stage for how we hope to handle forms in our application going forward. As the number of form hooks starts to grow, it may even make sense to colocate them with the components they server. For now, our current system will do nicely.

Ready?

Here's what goes in our new custom hook:

useLoginAndRegistrationForm.js
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { useAuthenticatedUser } from "hooks/auth/useAuthenticatedUser"
import { extractErrorMessages } from "utils/errors"
import validation from "utils/validation"
export const useLoginAndRegistrationForm = ({ isLogin = false }) => {
const navigate = useNavigate()
const { user, error: authError, isLoading, isAuthenticated } = useAuthenticatedUser()
const [form, setForm] = useState({
username: "",
email: "",
password: "",
passwordConfirm: "",
})
const [agreedToTerms, setAgreedToTerms] = useState(false)
const [errors, setErrors] = useState({})
const [hasSubmitted, setHasSubmitted] = useState(false)
const authErrorList = extractErrorMessages(authError)
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 handlePasswordConfirmChange = (value) => {
setErrors((errors) => ({
...errors,
passwordConfirm: form.password !== value ? `Passwords do not match.` : null,
}))
setForm((form) => ({ ...form, passwordConfirm: value }))
}
const getFormErrors = () => {
const formErrors = []
if (errors.form) {
formErrors.push(errors.form)
}
if (hasSubmitted && authErrorList.length) {
const additionalErrors = isLogin ? [`Invalid credentials. Please try again.`] : authErrorList
return formErrors.concat(additionalErrors)
}
return formErrors
}
// if the user is already authenticated, redirect them to the "/profile" page
useEffect(() => {
if (user?.email && isAuthenticated) {
navigate("/profile")
}
}, [user, navigate, isAuthenticated])
return {
form: isLogin ? { email: form.email, password: form.password } : form,
setForm,
errors,
setErrors,
isLoading,
getFormErrors,
hasSubmitted,
setHasSubmitted,
handleInputChange,
validateInput,
agreedToTerms,
setAgreedToTerms,
handlePasswordConfirmChange,
}
}

Now this may seem like a lot, but we've actually consolidated the logic held in both form components into a single hook. We'll use a single parameter to differentiate how the hook should handle the LoginForm component and how it should handle the RegistrationForm component.

The only real differences are the form object we return and the error messages displayed. Since the login form doesn't need a username or passwordConfirm field, we exclude those from the return value for that form. We also coalesce all login auth errors into a single Invalid credentials. Please try again. message. The signup form will still display authentication error messages sent from our FastAPI server like:

  • "That email is already taken. Use it to login or choose another."
  • "That username is already taken. Please choose another."
  • "You must agree to the terms and conditions".

As mentioned previously, the one thing this hook doesn't manage is how to submit the form. Those implementation details are left up to the component. Let's see what our LoginForm component looks like now:

LoginForm.js
import React from "react"
import { connect } from "react-redux"
import { Link } from "react-router-dom"
import {
useLoginAndRegistrationForm
} from "hooks/ui/useLoginAndRegistrationForm"
import {
Actions as authActions,
FETCHING_USER_FROM_TOKEN_SUCCESS
} from "redux/auth"
import {
EuiButton,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldPassword,
EuiSpacer
} from "@elastic/eui"
import styled from "styled-components"
const LoginFormWrapper = styled.div`
padding: 2rem;
`
const NeedAccountLink = styled.span`
font-size: 0.8rem;
`
function LoginForm({ requestUserLogin }) {
const {
form,
setForm,
errors,
setErrors,
isLoading,
getFormErrors,
validateInput,
handleInputChange,
setHasSubmitted,
} = useLoginAndRegistrationForm({ isLogin: true })
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
}
setHasSubmitted(true)
const action = await requestUserLogin({ email: form.email, password: form.password })
// reset the password form state if the login attempt is not successful
if (action?.type !== FETCHING_USER_FROM_TOKEN_SUCCESS) {
setForm((form) => ({ ...form, password: "" }))
}
}
return (
<LoginFormWrapper>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(getFormErrors().length)}
error={getFormErrors()}
>
<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="Password"
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 isLoading={isLoading}>
Submit
</EuiButton>
</EuiForm>
<EuiSpacer size="xl" />
<NeedAccountLink>
Need an account? Sign up <Link to="/registration">here</Link>.
</NeedAccountLink>
</LoginFormWrapper>
)
}
export default connect(null, { requestUserLogin: authActions.requestUserLogin })(LoginForm)

Everything is already baked into our custom hook, so we simply destructure the needed values from the hook's return value and use them in our component.

Our RegistrationForm component should look very similar. Let's see what we have now:

RegistrationForm.js
import React from "react"
import { connect } from "react-redux"
import {
Actions as authActions,
FETCHING_USER_FROM_TOKEN_SUCCESS
} from "redux/auth"
import {
useLoginAndRegistrationForm
} from "hooks/ui/useLoginAndRegistrationForm"
import {
EuiButton,
EuiCheckbox,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldPassword,
EuiSpacer
} from "@elastic/eui"
import { Link } from "react-router-dom"
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;
`
function RegistrationForm({ registerUser }) {
const {
form,
setForm,
errors,
setErrors,
isLoading,
getFormErrors,
setHasSubmitted,
handleInputChange,
validateInput,
agreedToTerms,
setAgreedToTerms,
handlePasswordConfirmChange,
} = useLoginAndRegistrationForm({ isLogin: false })
const handleSubmit = async (e) => {
e.preventDefault()
setErrors({})
// 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
}
if (!agreedToTerms) {
setErrors((errors) => ({ ...errors, form: `You must agree to the terms and conditions.` }))
return
}
setHasSubmitted(true)
const action = await registerUser({
username: form.username,
email: form.email,
password: form.password,
})
if (action?.type !== FETCHING_USER_FROM_TOKEN_SUCCESS) {
setForm((form) => ({ ...form, password: "", passwordConfirm: "" }))
}
}
return (
<RegistrationFormWrapper>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(getFormErrors().length)}
error={getFormErrors()}
>
<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="Password"
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="Confirm password"
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) => setAgreedToTerms(e.target.checked)}
/>
<EuiSpacer />
<EuiButton type="submit" isLoading={isLoading} fill>
Sign Up
</EuiButton>
</EuiForm>
<EuiSpacer size="xl" />
<NeedAccountLink>
Already have an account? Log in <Link to="/login">here</Link>.
</NeedAccountLink>
</RegistrationFormWrapper>
)
}
export default connect(null, {
registerUser: authActions.registerNewUser,
})(RegistrationForm)

Nothing too different here.

Again, we call the useLoginAndRegistrationForm form at the top of the component, destructure out the values we need, and then use them to render the form and handle the form submission process.

Give them both a whirl to make sure things are working as they should. Create a new dummy user via the registration process and then navigate around the app. Then, logout and attempt to sign in with the credentials for the recently created user.

Works like a charm, doesn't it?

Here's the final codesandbox showing the current state of our app with all the 'fixins.

Check it out on Code Sandbox

phresh-frontend-part-10-implementing-the-useLoginAndRegistrationForm-hook

We've made quite a few updates in this post. More than enough to justify wrapping it up for the evening.

Wrapping Up and Resources

Our application is now leveraging multiple custom hooks to handle displaying toasts, injecting the authenticated user into components, protecting routes from unauthenticated users, and handling form interactions for our authentication process. This pattern should pave the road for all architectural designs going forward.

Next time we convene, we'll be taking a look at populating cleaning jobs with their offers so we can start compiling statistics for the currently authenticated user.

  • Building Your Own Hooks - guide from the React docs
  • useHooks.com - Labeled as site for easy to understand custom React hook recipes
  • rooks - Titled as a collection of React hooks for "everybody"
  • rehooks - Github repo of all things React hooks