Refactoring Our UI Into Hooks - Part II

undraw svg icon

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

Previously, we refactored the majority of our auth and UI related logic into composable hooks. Doing so made our components leaner and gave us a framework by which to structure future improvements to our frontend.

We'll use that same approach in this post, but we'll shift the focus towards cleaning jobs. Keep in mind that we're gearing up for handling evaluations and aggregate statistics. The bulk of the work we do here should make things easier in that regard.

Updating the cleanings redux slice

Currently, the process by which we manage and view individual cleaning jobs is clunky. We're going to clean up our redux/cleanings.js file a bit and then follow the pattern we adopted in our last post, moving logic out of components and into custom hooks.

Before we begin, tweak the redux/initialState.js file slightly.

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: {},
activeCleaningId: null
},
offers: {
isLoading: false,
isUpdating: false,
error: null,
data: {}
},
feed: {
isLoading: false,
error: null,
data: {},
hasNext: {}
},
ui: {
toastList: []
}
}

Just a one line change that precludes what's to come in our redux refactor.

Ok, now let's pop open that redux/cleanings.js file and make a few changes.

redux/cleanings.js
import initialState from "redux/initialState"
import { REQUEST_LOG_USER_OUT } from "redux/auth"
import apiClient from "services/apiClient"
// ...other code
export default function cleaningsReducer(state = initialState.cleanings, action = {}) {
switch (action.type) {
case FETCH_CLEANING_JOB_BY_ID:
return {
...state,
isLoading: true
}
case FETCH_CLEANING_JOB_BY_ID_SUCCESS:
return {
...state,
isLoading: false,
error: null,
data: {
...state.data,
[action.data.id]: action.data,
},
activeCleaningId: action.data.id,
}
case FETCH_CLEANING_JOB_BY_ID_FAILURE:
return {
...state,
isLoading: false,
error: action.error,
}
case CLEAR_CURRENT_CLEANING_JOB:
return {
...state,
activeCleaningId: null,
}
// ...other code
case UPDATE_CLEANING_JOB_SUCCESS:
return {
...state,
isUpdating: false,
error: null,
data: {
...state.data,
[action.data.id]: action.data,
}
}
// ...other code
default:
return state
}
}
export const Actions = {}
Actions.clearCurrentCleaningJob = () => ({ type: CLEAR_CURRENT_CLEANING_JOB })
Actions.createCleaningJob = ({ newCleaning }) => {
return apiClient({
url: `/cleanings/`,
method: `POST`,
types: {
REQUEST: CREATE_CLEANING_JOB,
SUCCESS: CREATE_CLEANING_JOB_SUCCESS,
FAILURE: CREATE_CLEANING_JOB_FAILURE
},
options: {
data: { new_cleaning: newCleaning },
params: {}
}
})
}
Actions.fetchCleaningJobById = ({ cleaningId }) => {
return apiClient({
url: `/cleanings/${cleaningId}/`,
method: `GET`,
types: {
REQUEST: FETCH_CLEANING_JOB_BY_ID,
SUCCESS: FETCH_CLEANING_JOB_BY_ID_SUCCESS,
FAILURE: FETCH_CLEANING_JOB_BY_ID_FAILURE
},
options: {
data: {},
params: {}
}
})
}
Actions.updateCleaningJob = ({ cleaningId, cleaningUpdate }) => {
return apiClient({
url: `/cleanings/${cleaningId}/`,
method: `PUT`,
types: {
REQUEST: UPDATE_CLEANING_JOB,
SUCCESS: UPDATE_CLEANING_JOB_SUCCESS,
FAILURE: UPDATE_CLEANING_JOB_FAILURE
},
options: {
data: { cleaning_update: cleaningUpdate },
params: {}
}
})
}
// ...other code

All of the updates to our action creator functions are simply cosmetic. Mixing snake case and camel case is usually not a good idea as it makes for an inconsistent api. Now, tools like VS code will handle autocompletion for us. Even so, it's good practice to stick with standard JavaScript snake case until we need to actually pass camel case parameters to our FastAPI backend. So we've converted parameters like cleaning_id to cleaningId.

The other changes we've made are in the cleaningsReducer. We are also standardizing how we handle receiving cleaning resources from our FastAPI server, which is a much needed change. We'll be keeping all jobs indexed by id inside the data key, creating a simple lookup table when we need to access a particular resource. On top of that, we're simply storing the active id of any successfully fetched cleaning job and caching it in activeCleaningId. Any clunky update logic has been replace as well. All of these changes will break things as they currently stand, but that's ok. We'll fix them in a minute.

Oh yeah, we also switched to absolute imports here as well. All is well at the moment.

Next up is creating a new custom hook that we'll use to manage any individual cleaning resource.

The useSingleCleaningJob Hook

Go ahead and create a new directory inside our hooks folder called cleanings. Add a new file to it called useSingleCleaningJob.js.

mkdir src/hooks/cleanings
touch src/hooks/cleanings/useSingleCleaningJob.js

While we're at it, let's also create a new file in the utils directory called cleanings.js. Over the next couple steps we'll be putting a few helper methods to good use. Now is as good a time as ever to start adding code there.

touch src/utils/cleanings.js

First things first, add the following code to the useSingleCleaningJob.js file:

useSingleCleaningJob.js
import { useEffect } from "react"
import { useDispatch, useSelector, shallowEqual } from "react-redux"
import { useAuthenticatedUser } from "hooks/auth/useAuthenticatedUser"
import { Actions as cleaningActions } from "redux/cleanings"
import { userIsOwnerOfCleaningJob } from "utils/cleanings"
export const useSingleCleaningJob = (cleaningId) => {
const dispatch = useDispatch()
const { user } = useAuthenticatedUser()
const cleaningJob = useSelector((state) => state.cleanings.data[cleaningId], shallowEqual)
const activeCleaningId = useSelector((state) => state.cleanings.activeCleaningId)
const isLoading = useSelector((state) => state.cleanings.isLoading)
const isUpdating = useSelector((state) => state.cleanings.isUpdating)
const error = useSelector((state) => state.cleanings.error, shallowEqual)
const userIsOwner = userIsOwnerOfCleaningJob(cleaningJob, user)
useEffect(() => {
if (cleaningId && !cleaningJob) {
dispatch(cleaningActions.fetchCleaningJobById({ cleaningId }))
}
return () => {
dispatch(cleaningActions.clearCurrentCleaningJob())
}
}, [dispatch, cleaningId, cleaningJob])
return {
error,
isLoading,
isUpdating,
cleaningJob,
userIsOwner,
activeCleaningId,
}
}

A few things to take note of here.

At the top of the file we're importing some standard redux stuff and a yet-to-be-created function called userIsOwnerOfCleaningJob. Its job is self-explanatory. We should be able to pass it a user object and a cleaning object, and we should get back a boolean indicating whether or not that user owns the cleaning resource in question. We're also importing the useAuthenticatedUser hook that we defined in our previous post. Once again, centralizing our app's auth logic in this hook is proving fruitful.

The useSingleCleaningJob function takes in a single parameter, the id of the cleaning job. We then use that id in two places.

First, we pull the cleaning job from the cleanings slice of our redux state tree. We also grab the isLoading and isUpdating flags, along with any errors. We then pass both the cleaning job and the user returned by our useAuthenticatedUser hook into the userIsOwnerOfCleaningJob utility function to determin the value of the userIsOwner boolean.

Finally, we run a useEffect hook that checks if the cleaningId is valid and that we don't already have a cleaningJob in state. If both these conditions are met, we dispatch the fetchCleaningJobById action creator and request the cleaning resource from our FastAPI backend.

At the end, we return all relevant data that consumers of this hook might need.

All that's left is to implement the userIsOwnerOfCleaningJob function.

utils/cleanings.js
export const userIsOwnerOfCleaningJob = (cleaning, user) => {
if (cleaning?.owner?.id === user?.id) return true
if (cleaning?.owner === user?.id) return true
return false
}

Three situations are being accounted for here.

  • When the function is provided a cleaning job where the owner is a user object, we compare the id of the owner with the id of the user passed to the function. If they match, we return true.
  • When the cleaning job has an owner property that is only an id, we simply compare that to the id of the user and return true if they match.
  • If neither of the two previous situations match, or if null or undefined is passed in for either the cleaning or user parameters, none of the conditions will match and the function simply returns false.

Now onto the tricky part. We'll need to update any component that depends on accessing a single cleaning job from redux.

Before we do anything else, make a small change to the CleaningJobsPage.js component.

CleaningJobsPage.js
import React from "react"
import { Routes, Route } from "react-router-dom"
import { CleaningJobsHome, CleaningJobView, NotFoundPage } from "components"
export default function CleaningJobsPage() {
return (
<>
<Routes>
<Route path="/" element={<CleaningJobsHome />} />
<Route path=":cleaningId/*" element={<CleaningJobView />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</>
)
}

Here we're specifying the route param as cleaningId instead of cleaning_id. Again, we're simply staying consistent with our variable casing.

Next, open up the CleaningJobView component and update it like so:

CleaningJobView.js
import React from "react"
import { Routes, Route, useNavigate } from "react-router-dom"
import { useSingleCleaningJob } from "hooks/cleanings/useSingleCleaningJob"
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiLoadingSpinner,
EuiTitle
} from "@elastic/eui"
import {
CleaningJobCard,
CleaningJobEditForm,
CleaningJobOffersTable,
NotFoundPage,
PermissionsNeeded,
UserAvatar
} from "components"
import { useParams } from "react-router-dom"
import styled from "styled-components"
const StyledEuiPage = styled(EuiPage)`
flex: 1;
display: flex;
flex-direction: column;
`
const StyledFlexGroup = styled(EuiFlexGroup)`
padding: 1rem;
`
export default function CleaningJobView() {
const navigate = useNavigate()
const { cleaningId } = useParams()
const {
cleaningJob,
error,
isLoading,
isUpdating,
activeCleaningId,
userIsOwner
} = useSingleCleaningJob(cleaningId)
if (isLoading) return <EuiLoadingSpinner size="xl" />
if (!cleaningJob && activeCleaningId !== cleaningId) return <NotFoundPage />
const editJobButton = userIsOwner ? (
<EuiButtonIcon iconType="documentEdit" aria-label="edit" onClick={() => navigate(`edit`)} />
) : null
const goBackButton = (
<EuiButtonEmpty
iconType="sortLeft"
size="s"
onClick={() => navigate(`/cleaning-jobs/${cleaningJob.id}`)}
>
back to job
</EuiButtonEmpty>
)
const viewCleaningJobElement = (
<CleaningJobCard
offersIsLoading={null}
cleaningJob={cleaningJob}
isOwner={userIsOwner}
createOfferForCleaning={null}
userOfferForCleaningJob={null}
/>
)
const editCleaningJobElement = (
<PermissionsNeeded
element={<CleaningJobEditForm cleaningId={cleaningId} />}
isAllowed={userIsOwner}
/>
)
const cleaningJobOffersTableElement = userIsOwner ? (
<CleaningJobOffersTable offers={[]} handleAcceptOffer={null} offersIsLoading={null} />
) : null
return (
<StyledEuiPage>
<EuiPageBody component="section">
<EuiPageContent verticalPosition="center" horizontalPosition="center" paddingSize="none">
<StyledFlexGroup alignItems="center" direction="row" responsive={false}>
<EuiFlexItem>
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
direction="row"
responsive={false}
>
<EuiFlexItem grow={false}>
<UserAvatar size="xl" user={cleaningJob.owner} intialsLength={2} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle>
<p>@{cleaningJob.owner?.username}</p>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Routes>
<Route path="/" element={editJobButton} />
<Route path="/edit" element={goBackButton} />
</Routes>
</EuiFlexItem>
</StyledFlexGroup>
<EuiPageContentBody>
<Routes>
<Route path="/" element={viewCleaningJobElement} />
<Route path="/edit" element={editCleaningJobElement} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</EuiPageContentBody>
</EuiPageContent>
<Routes>
<Route path="/" element={cleaningJobOffersTableElement} />
</Routes>
</EuiPageBody>
</StyledEuiPage>
)
}

Compared to what this component looked like before, this is a signficant upgrade. To be fair, we've left out anything having to do with offers, so we've added a number of null and empty array values to children of this component. Once we start handling fetching offers for a single cleaning job, we'll update these values.

We're no longer using any redux related code directly in the component. Everything we need comes directly from our useSingleCleaningJob hook and is passed to the components that need them. Both the CleaningJobCard and CleaningJobEditForm use the same cleaning job, so we pass the cleaningJob object and isOwner boolean to the CleaningJobCard component and cleaningId to the CleaningJobEditForm component. At the moment, CleaningJobEditForm has its own logic on how to use that id to render its form properly, but that could probably be improved.

In fact, it would probably make sense to extract all of the logic within the CleaningJobEditForm into its own hook. Why don't we do that now?

Befor we move on, here's a codesandbox with all the code we've written up to this point:

Check it out on Code Sandbox

phresh-frontend-part-11-the-useSingleCleaningJob-hook

Check to make sure everything is in order.

Once that's done, we'll get to building our useCleaningJobForm hook.

The useCleaningJobForm hook

Go ahead and create a new file:

touch src/hooks/ui/useCleaningJobForm.js

Could this file have gone in the hooks/cleanings directory? Sure. But it's really more UI related, so we're placing it there instead.

Since the same fields are being used by the CleaningJobCreateForm and CleaningJobEditForm components, we'll design our hook to support both cases. As was the case with our LoginForm and RegistrationForm, there's quite a bit of shared logic between the two. Fortunately, we won't need to differentiate between the two situations explicitly like we did with our authentication form. We'll just use a cleaningId to determine the form's specifics.

Here's how we'll do it.

useCleaningJobForm.js
import React, { useState, Fragment, useEffect } from "react"
import { useSingleCleaningJob } from "hooks/cleanings/useSingleCleaningJob"
import { EuiText } from "@elastic/eui"
import { extractErrorMessages } from "utils/errors"
import validation from "utils/validation"
const cleaningTypeOptions = [
{
value: "dust_up",
inputDisplay: "Dust Up",
dropdownDisplay: (
<Fragment>
<strong>Dust Up</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
A minimal clean job. Dust shelves and mantels, tidy rooms, and sweep floors.
</p>
</EuiText>
</Fragment>
),
},
{
value: "spot_clean",
inputDisplay: "Spot Clean",
dropdownDisplay: (
<Fragment>
<strong>Spot Clean</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
A standard clean job. Vacuum all indoor spaces, sanitize surfaces, and disinfect targeted areas. Bathrooms,
tubs, and toilets can be added on for an additional charge.
</p>
</EuiText>
</Fragment>
),
},
{
value: "full_clean",
inputDisplay: "Deep Clean",
dropdownDisplay: (
<Fragment>
<strong>Deep Clean</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
A complete clean job. Mop tile floors, scrub out tough spots, and a guaranteed clean residence upon
completion. Dishes, pots, and pans included in this package.
</p>
</EuiText>
</Fragment>
),
},
]
export const useCleaningJobForm = (cleaningId) => {
const { cleaningJob, isLoading, isUpdating, error } = useSingleCleaningJob(cleaningId)
const [form, setForm] = useState({
name: cleaningJob?.name || "",
description: cleaningJob?.description || "",
price: cleaningJob?.price || "",
cleaning_type: cleaningJob?.cleaning_type || cleaningTypeOptions[0].value,
})
const [errors, setErrors] = useState({})
const [hasSubmitted, setHasSubmitted] = useState(false)
const cleaningErrorList = extractErrorMessages(error)
useEffect(() => {
if (cleaningJob) {
setForm((form) => ({ ...cleaningJob }))
}
}, [cleaningJob])
const validateInput = (label, value) => {
const isValid = validation?.[label] ? validation?.[label]?.(value) : true
setErrors((errors) => ({ ...errors, [label]: !isValid }))
}
const onInputChange = (label, value) => {
validateInput(label, value)
setForm((state) => ({ ...state, [label]: value }))
}
const onCleaningTypeChange = (cleaning_type) => {
setForm((state) => ({ ...state, cleaning_type }))
}
const getFormErrors = () => {
const formErrors = []
if (errors.form) {
formErrors.push(errors.form)
}
if (hasSubmitted && cleaningErrorList.length) {
return formErrors.concat(cleaningErrorList)
}
return formErrors
}
return {
form,
setForm,
errors,
isLoading,
isUpdating,
hasSubmitted,
getFormErrors,
setHasSubmitted,
onInputChange,
validateInput,
onCleaningTypeChange,
cleaningTypeOptions,
}
}

This hook is much larger in size, but is actually very similar to our useLoginAndRegistrationForm hook. The main difference is that we have some shared UI used in the cleaning type dropdown. Right beneath the imports, we have the cleaningTypeOptions array which we have extracted from both components and placed here. With this one simple change, we've already removed 45 lines of code from our app with no reduction in functionality. That's a win.

We also take advantage of our freshly minted useSingleCleaningJob hook. The useCleaningJobForm takes in a single parameter - cleaningId - and passes it to useSingleCleaningJob to gain access to any currently existing cleaning jobs with that id. If the id doesn't exist, then no cleaning job will be returned. No harm no foul.

However, notice that our form state defaults to all the attributes of the cleaningJob, if it exists. In the case that no cleaning is present, we use empty strings and the first value in the cleaningTypeOptions array. The useEffect hook below our state initializations is run each time the cleaningJob value changes. When it receives a valid cleanignJob, it updates the form state with whatever attributes the cleaning resource already has. By doing this, we have made our form flexible enough to support existing cleaning jobs and ones that are yet to be made. That is why we'll be able to use this hook for both our CleaningJobCreateForm and CleaningJobEditForm components.

The rest of the hooks is code we've seen before, so we won't dive into it any further.

Let's see our work in action.

Open up the CleaningJobEditForm and make the following adjustments:

CleaningJobEditForm.js
import React from "react"
import { connect } from "react-redux"
import { useNavigate } from "react-router-dom"
import { Actions as cleaningActions } from "redux/cleanings"
import { useCleaningJobForm } from "hooks/ui/useCleaningJobForm"
import {
EuiButton,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldNumber,
EuiSuperSelect,
EuiSpacer,
EuiTextArea,
} from "@elastic/eui"
import styled from "styled-components"
const Wrapper = styled.div`
padding: 1rem 2rem;
`
function CleaningJobEditForm({ cleaningId, updateCleaning }) {
const navigate = useNavigate()
const {
form,
errors,
setErrors,
isUpdating,
getFormErrors,
validateInput,
onCleaningTypeChange,
setHasSubmitted,
onInputChange,
cleaningTypeOptions
} = useCleaningJobForm(cleaningId)
const handleSubmit = async (e) => {
e.preventDefault()
Object.keys(form).forEach((label) => validateInput(label, form[label]))
if (!Object.values(form).every((value) => Boolean(value))) {
setErrors((errors) => ({ ...errors, form: `You must fill out all fields.` }))
return
}
setHasSubmitted(true)
const res = await updateCleaning({ cleaningId, cleaningUpdate: { ...form } })
if (res.success) {
navigate(`/cleaning-jobs/${cleaningId}`)
}
}
return (
<Wrapper>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(getFormErrors().length)}
error={getFormErrors()}
>
<EuiFormRow
label="Job Title"
helpText="What do you want cleaners to see first?"
isInvalid={Boolean(errors.name)}
error={`Please enter a valid name.`}
>
<EuiFieldText name="name" value={form.name} onChange={(e) => onInputChange(e.target.name, e.target.value)} />
</EuiFormRow>
<EuiFormRow label="Select a cleaning type">
<EuiSuperSelect
options={cleaningTypeOptions}
valueOfSelected={form.cleaning_type}
onChange={(value) => onCleaningTypeChange(value)}
itemLayoutAlign="top"
hasDividers
/>
</EuiFormRow>
<EuiFormRow
label="Hourly Rate"
helpText="List a reasonable price for each hour of work the employee logs."
isInvalid={Boolean(errors.price)}
error={`Price should match the general format: 9.99`}
>
<EuiFieldNumber
name="price"
icon="currency"
placeholder="19.99"
value={form.price}
onChange={(e) => onInputChange(e.target.name, e.target.value)}
/>
</EuiFormRow>
<EuiFormRow
label="Job Description"
helpText="What do you want prospective employees to know about this opportunity?"
isInvalid={Boolean(errors.description)}
error={`Please enter a valid input.`}
>
<EuiTextArea
name="description"
placeholder="I'm looking for..."
value={form.description}
onChange={(e) => onInputChange(e.target.name, e.target.value)}
/>
</EuiFormRow>
<EuiSpacer />
<EuiButton type="submit" isLoading={isUpdating} fill iconType="save" iconSide="right">
Update Cleaning
</EuiButton>
</EuiForm>
</Wrapper>
)
}
export default connect(null, {
updateCleaning: cleaningActions.updateCleaningJob,
})(CleaningJobEditForm)

Like the other form components, our new version has been reduced in size significantly.

And in fact, the same will be true of the CleaningJobCreateForm component. Open up that file and modify it accordingly:

CleaningJobCreateForm.js
import React from "react"
import { connect } from "react-redux"
import { useNavigate } from "react-router-dom"
import { Actions as cleaningActions } from "redux/cleanings"
import { useCleaningJobForm } from "hooks/ui/useCleaningJobForm"
import {
EuiButton,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldNumber,
EuiSuperSelect,
EuiSpacer,
EuiTextArea
} from "@elastic/eui"
function CleaningJobCreateForm({ createCleaning }) {
const navigate = useNavigate()
const {
form,
errors,
setErrors,
isLoading,
validateInput,
setHasSubmitted,
getFormErrors,
cleaningTypeOptions,
onCleaningTypeChange,
onInputChange
} = useCleaningJobForm()
const handleSubmit = async (e) => {
e.preventDefault()
Object.keys(form).forEach((label) => validateInput(label, form[label]))
if (!Object.values(form).every((value) => Boolean(value))) {
setErrors((errors) => ({ ...errors, form: `You must fill out all fields.` }))
return
}
setHasSubmitted(true)
const res = await createCleaning({ new_cleaning: { ...form } })
if (res.success) {
const cleaningId = res.data?.id
navigate(`/cleaning-jobs/${cleaningId}`)
}
}
return (
<>
<EuiForm
component="form"
onSubmit={handleSubmit}
isInvalid={Boolean(getFormErrors().length)}
error={getFormErrors()}
>
<EuiFormRow
label="Job Title"
helpText="What do you want cleaners to see first?"
isInvalid={Boolean(errors.name)}
error={`Please enter a valid name.`}
>
<EuiFieldText
name="name"
value={form.name}
onChange={(e) => onInputChange(e.target.name, e.target.value)}
/>
</EuiFormRow>
<EuiFormRow label="Select a cleaning type">
<EuiSuperSelect
options={cleaningTypeOptions}
valueOfSelected={form.cleaning_type}
onChange={(value) => onCleaningTypeChange(value)}
itemLayoutAlign="top"
hasDividers
/>
</EuiFormRow>
<EuiFormRow
label="Hourly Rate"
helpText="List a reasonable price for each hour of work the employee logs."
isInvalid={Boolean(errors.price)}
error={`Price should match the general format: 9.99`}
>
<EuiFieldNumber
name="price"
icon="currency"
placeholder="19.99"
value={form.price}
onChange={(e) => onInputChange(e.target.name, e.target.value)}
/>
</EuiFormRow>
<EuiFormRow
label="Job Description"
helpText="What do you want prospective employees to know about this opportunity?"
isInvalid={Boolean(errors.description)}
error={`Please enter a valid input.`}
>
<EuiTextArea
name="description"
placeholder="I'm looking for..."
value={form.description}
onChange={(e) => onInputChange(e.target.name, e.target.value)}
/>
</EuiFormRow>
<EuiSpacer />
<EuiButton type="submit" isLoading={isLoading} fill>
Create Cleaning
</EuiButton>
</EuiForm>
</>
)
}
export default connect(null, {
createCleaning: cleaningActions.createCleaningJob
})(CleaningJobCreateForm)

A similar feel here. An almost 100 line reduction in code. That is the magic of using custom hooks. Encapsulated logic, reduced component size, and organized ui management.

Take the newly updated components for a spin. Create a new cleaning job and then edit it.

It works, huh?

Let's put the finishing touches on our app by adding a success toast whenever the user updates a cleaning job.

Open back up the redux/cleanings.js file and add the following:

redux/cleanings.js
// ...other code
Actions.updateCleaningJob = ({ cleaningId, cleaningUpdate }) => {
return (dispatch) => {
return dispatch(
apiClient({
url: `/cleanings/${cleaningId}/`,
method: `PUT`,
types: {
REQUEST: UPDATE_CLEANING_JOB,
SUCCESS: UPDATE_CLEANING_JOB_SUCCESS,
FAILURE: UPDATE_CLEANING_JOB_FAILURE,
},
options: {
data: { cleaning_update: cleaningUpdate },
params: {},
},
onSuccess: (res) => {
dispatch(
uiActions.addToast({
id: `update-cleaning-success`,
title: "Success!",
color: "success",
iconType: "checkInCircleFilled",
toastLifeTimeMs: 15000,
text: "Your cleaning job has been updated.",
})
)
return {
type: UPDATE_CLEANING_JOB_SUCCESS,
success: true,
status: res.status,
data: res.data,
}
},
})
)
}
}
// ...other code

In the onSuccess hook for our updateCleaningJob action creator, we take advantage of our global ui toast list and create a success toast for when cleaning jobs have been successfully updated.

Go in an edit another post and watch the fruits of our labor in action.

Here's a codesandbox with all the updates up to this point.

Check it out on Code Sandbox

phresh-frontend-part-11-the-useCleaningJobForm-hook

We have a lot of things that feel right here. Not everything works as before, however.

For instance, our cleaning jobs are still missing offers. We could return to our old method of fetching offers for a cleaning job when a component is mounted, but it probably makes more sense to populate cleaning jobs with their associated offers.

We'll handle that in our next post as we head back to FastAPI land for some backend updates.

Wrapping Up and Resources

That should just about do it for our biggest frontend refactor yet. Our React application is becoming more organized and easier to reason about. We've also solidifed the core logic for handling cleaning jobs, so that we can move on to evaluations with confidence that the rest of our system is working effectively.

Next up, we'll start populating cleaning jobs received from our backend with offers the authenticated user is allowed to access.

  • 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