Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced: Form state management step 2 – useLoginForm #11

Draft
wants to merge 6 commits into
base: lessons/08-react-apis
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/features/login/lib/hooks/useLoginForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { ChangeEvent, FocusEvent, FormEvent } from 'react'
import { useCallback, useMemo, useState } from 'react'

import { mapObject } from '~/utils/mapObject'

type TFields = 'email' | 'password'
type TValues = { [key in TFields]: string }
type TValidator = <TValue>(value: TValue) => void | string

const initials = {
values: {
email: '',
password: '',
},

touched: {
email: false,
password: false,
},
}

const validators: { [key in TFields]: TValidator } = {
email: (value) => {
if (typeof value !== 'string') return 'Invalid e-mail value type'
if (!value) return 'E-mail is required'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value)) return 'Invalid e-mail'
},

password: (value) => {
if (typeof value !== 'string') return 'Invalid password value type'
if (!value) return 'Password is required'
},
}

/**
* Controls login form state.
*/
const useLoginForm = () => {
const [values, setValues] = useState<TValues>(initials.values)
const [touched, setTouched] = useState(initials.touched)
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<Error | null>(null)

// Compute errors based on validators.
const errors = useMemo(
() => mapObject(validators, (fn, field) => fn(values[field]) ?? null),
[values]
)

// Compute the validity of field.
const invalid = useMemo(() => mapObject(errors, Boolean), [errors])

// Form is invalid when any field is invalid.
const isValid = useMemo(
() => !Object.values(invalid).some(Boolean),
[invalid]
)

/**
* Form submit handler constructor.
*/
const handleSubmit = useCallback(
(handler: (values: TValues) => Promise<void | Error>) =>
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault()

// Touch all fields uppon submission trial, so that
// errors might be shown to the UI.
setTouched((touched) => mapObject(touched, () => true))

if (isValid) {
setIsSubmitting(true)

const handling = handler(values)

handling
.catch(setSubmitError)
// Ensure we reset submit state even if promise fails
.finally(() => setIsSubmitting(false))
}
},
[values, isValid]
)

/**
* Input value change handler.
*/
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target

// Update value.
setValues((values) => ({ ...values, [name]: value }))
}, [])

/**
* Input value blur handler.
*/
const handleBlur = useCallback((e: FocusEvent<HTMLInputElement>) => {
const { name } = e.target

setTouched((touched) => ({ ...touched, [name]: true }))
}, [])

return {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not gonna pretend I understand all the type syntax used, but it's nice to what potentially Formik or ReactHooks are doing under the hood.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree. Until this point I was pretty confident whats going on in our project, but this one seems more like an expert approach. Nice showcase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OndrejDrapalik @matyasmihalka this definitely isn't the most straight-forward code. In fact, this is why we decided it wasn't necessary to include as our "main track" in the application.

The idea here is really to showcase how libraries such as Formik are made, and how complex proper form state handling can be without those libraries.

I'll give you a hint though: React Hook Forms is way, waaay more complex than Formik, as it takes over React entirely, building it's solution on top of traps and Proxy states, executing hooks dynamically based on state consumption. Quite neat. If you are interested, we could have a chat about it or explore a simpler isolated solution to showcase the technique.

values,
touched,
errors,
invalid,
isValid,
isSubmitting,
submitError,
handleSubmit,
handleChange,
handleBlur,
} as const
}

export { useLoginForm }
75 changes: 46 additions & 29 deletions src/features/login/pages/LoginPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { NextPage } from 'next'
import type { FormEvent } from 'react'
import { useState } from 'react'

import { Button } from '~/features/ui/components/Button'
import { Container } from '~/features/ui/components/Container'
import { Input } from '~/features/ui/components/Input'
import { LayoutExternal } from '~/features/ui/components/LayoutExternal'
Expand All @@ -15,50 +12,70 @@ import {
ErrorMessage,
} from './styled'

import { useLoginForm } from '../../lib/hooks/useLoginForm'

export const LoginPage: NextPage = () => {
const [error, setError] = useState('')
const form = useLoginForm()

/**
* Login handler.
*/
const login = form.handleSubmit(
async (values) =>
await new Promise((resolve, reject) => {
console.log({ values })

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Simulate network delay.
setTimeout(() => {
// Mocking to represent login submit outcome.
const shouldFail = Math.random() < 0.5

alert('TODO')
}
if (shouldFail) {
reject(new Error('Something went terribly wrong!'))
} else {
alert('Success!')
resolve()
}
}, 1000)
})
)

return (
<LayoutExternal>
<Container>
<FormWrapper>
<Title>Sign in to Eventio.</Title>
{error ? (
<ErrorMessage>{error}</ErrorMessage>

{form.submitError ? (
<ErrorMessage>{form.submitError.message}</ErrorMessage>
) : (
<Description>Enter your details below.</Description>
)}
<form onSubmit={onSubmit}>
<Input label="Email" type="email" name="email" error={error} />

<form onSubmit={login}>
<Input
label="Email"
type="email"
name="email"
value={form.values.email}
error={form.touched.email ? form.errors.email : null}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>

<Input
label="Password"
type="password"
name="password"
error={error}
value={form.values.password}
error={form.touched.password ? form.errors.password : null}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
<p>
<SubmitButton>Sign In</SubmitButton>
</p>

{/*
Created just to showcase CSS animations.
To be removed. Please do not use style attribute.
*/}
<p style={{ marginTop: '1rem' }}>
<Button
type="button"
size="small"
accent="destructive"
onClick={() => setError(Date.now().toString())}
>
Trigger Error
</Button>
<SubmitButton disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting' : 'Sign In'}
</SubmitButton>
</p>
</form>
</FormWrapper>
Expand Down
13 changes: 7 additions & 6 deletions src/features/ui/components/Input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {
InputWrapper,
Label,
LabelText,
ErrorMessage,
PasswordToggle,
StyledInput,
} from './styled'

type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string
error?: string
error?: string | null
}

export const Input: FC<Props> = ({ label, name, type, error, ...rest }) => {
Expand All @@ -21,18 +22,16 @@ export const Input: FC<Props> = ({ label, name, type, error, ...rest }) => {

return (
<InputWrapper>
{/*
By changing the value of key prop, we're making the component
remount, which also triggers an attached animation again.
*/}
<Label hasError={Boolean(error)} key={error}>
<Label hasError={Boolean(error)}>
<StyledInput
placeholder={label}
name={name}
type={inputType}
{...rest}
/>

<LabelText>{label}</LabelText>

{type === 'password' && (
<PasswordToggle
isActive={isPasswordShown}
Expand All @@ -42,6 +41,8 @@ export const Input: FC<Props> = ({ label, name, type, error, ...rest }) => {
<EyeIcon />
</PasswordToggle>
)}

{error ? <ErrorMessage>{error}</ErrorMessage> : null}
</Label>
</InputWrapper>
)
Expand Down
4 changes: 4 additions & 0 deletions src/features/ui/components/Input/styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export const Label = styled.label<{ hasError?: boolean }>`
`}
`

export const ErrorMessage = styled.span`
color: ${colors.accent.destructive};
`

export const PasswordToggle = styled.button.attrs({ type: 'button' })<{
isActive: boolean
}>`
Expand Down
23 changes: 23 additions & 0 deletions src/utils/mapObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Maps through an object's properties.
*
* i.e.:
* mapObject({ foo: '' }, (value, key) => key) // { foo: 'foo' }
* mapObject({ foo: 1, bar: 3 }, (value) => value + 1) // { foo: 2, bar; 4 }
*/
const mapObject = <
TObject extends object,
TKeys extends keyof TObject,
TResult
>(
obj: TObject,
fn: (value: TObject[keyof TObject], key: TKeys, obj: TObject) => TResult
) =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => [
key,
fn(value, key as TKeys, obj),
])
) as { [key in TKeys]: TResult }

export { mapObject }