Skip to content

Commit 76ea8ed

Browse files
committed
feat: add hook and react forms
1 parent 7cc4a59 commit 76ea8ed

13 files changed

Lines changed: 262 additions & 26 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "next lint",
10-
"format": "prettier --write ."
10+
"format": "prettier --write .",
11+
"typecheck": "tsc --noEmit"
1112
},
1213
"dependencies": {
1314
"@hookform/resolvers": "3.10.0",

src/app/(forms)/hook-form/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { HookForm } from '@/components/HookForm/HookForm'
2+
3+
export default function HookFormPage() {
4+
return <HookForm />
5+
}

src/app/(forms)/layout.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Link from 'next/link'
2+
3+
export default function FormLayout({
4+
children,
5+
}: Readonly<{
6+
children: React.ReactNode
7+
}>) {
8+
return (
9+
<div>
10+
<div className="flex flex-row justify-center mb-4 gap-4">
11+
<Link href="/" className="underline">
12+
back to homepage
13+
</Link>
14+
</div>
15+
{children}
16+
</div>
17+
)
18+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ReactForm } from '@/components/ReactForm/ReactForm'
2+
3+
export default function ReactFormPage() {
4+
return <ReactForm />
5+
}

src/app/globals.css

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
@media (prefers-color-scheme: dark) {
1111
:root {
12-
--background: #0a0a0a;
12+
--background: #111827;
1313
--foreground: #ededed;
1414
}
1515
}
@@ -18,7 +18,6 @@ body {
1818
display: flex;
1919
flex-direction: column;
2020
align-items: center;
21-
justify-content: center;
2221
width: 100vw;
2322
min-height: 100vh;
2423
color: var(--foreground);

src/app/layout.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,32 @@
1-
import type { Metadata } from "next";
2-
import { Geist, Geist_Mono } from "next/font/google";
3-
import "./globals.css";
1+
import type { Metadata } from 'next'
2+
import { Geist, Geist_Mono } from 'next/font/google'
3+
import './globals.css'
44

55
const geistSans = Geist({
6-
variable: "--font-geist-sans",
7-
subsets: ["latin"],
8-
});
6+
variable: '--font-geist-sans',
7+
subsets: ['latin'],
8+
})
99

1010
const geistMono = Geist_Mono({
11-
variable: "--font-geist-mono",
12-
subsets: ["latin"],
13-
});
11+
variable: '--font-geist-mono',
12+
subsets: ['latin'],
13+
})
1414

1515
export const metadata: Metadata = {
16-
title: "React 19 forms tutorial",
17-
description: "React 19 forms tutorial",
18-
};
16+
title: 'React 19 forms tutorial',
17+
description: 'React 19 forms tutorial',
18+
}
1919

2020
export default function RootLayout({
2121
children,
2222
}: Readonly<{
23-
children: React.ReactNode;
23+
children: React.ReactNode
2424
}>) {
2525
return (
2626
<html lang="en">
27-
<body
28-
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29-
>
30-
{children}
27+
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
28+
<div className="py-8 px-4 min-w-screen min-h-screen flex flex-col items-center justify-start">{children}</div>
3129
</body>
3230
</html>
33-
);
31+
)
3432
}

src/app/page.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
import Link from 'next/link'
2+
13
export default function Home() {
24
return (
3-
<div className="min-w-full min-h-full flex flex-col items-center justify-center">
4-
Hello World
5+
<div>
6+
<h1 className="text-2xl font-bold">React 19 forms tutorial</h1>
7+
8+
<div className="flex flex-row justify-center mt-4 gap-4">
9+
<Link href="/hook-form" className="underline">
10+
Hook Form
11+
</Link>
12+
<Link href="/react-form" className="underline">
13+
React Form
14+
</Link>
15+
</div>
516
</div>
6-
);
17+
)
718
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import { useOptimistic, useTransition, type FC } from 'react'
4+
import { useForm } from 'react-hook-form'
5+
import { zodResolver } from '@hookform/resolvers/zod'
6+
7+
import type { FormSchema } from '@/types/form-types'
8+
import { formSchema } from '@/constants/validation-schema'
9+
import { formFields } from '@/constants/form-fields'
10+
11+
import { Button } from '../Button/Button'
12+
import { FieldWrapper } from '../FieldWrapper/FieldWrapper'
13+
import { Form } from '../Form/Form'
14+
import { Input } from '../Input/Input'
15+
import { Label } from '../Label/Label'
16+
import { slowFormRequest } from '@/utils/slow-form-request'
17+
18+
export const HookForm: FC = () => {
19+
const [isPending, startTransition] = useTransition()
20+
const [optimisticState, addOptimisticState] = useOptimistic<string[]>([])
21+
const {
22+
register,
23+
handleSubmit,
24+
reset,
25+
formState: { errors },
26+
} = useForm<FormSchema>({
27+
defaultValues: {
28+
firstName: '',
29+
lastName: '',
30+
note: '',
31+
inviteCode: '',
32+
},
33+
resolver: zodResolver(formSchema),
34+
})
35+
36+
const handleFormSubmit = (data: FormSchema) => {
37+
startTransition(async () => {
38+
try {
39+
addOptimisticState(['Submitting form...'])
40+
41+
await slowFormRequest(data)
42+
43+
addOptimisticState(['Form submitted successfully!'])
44+
// Keep the optimistic state for 1 second
45+
await new Promise((resolve) => setTimeout(resolve, 1000))
46+
47+
reset()
48+
} catch (error) {
49+
addOptimisticState([`Form submission failed! ${JSON.stringify(error)}`])
50+
}
51+
})
52+
}
53+
54+
return (
55+
<div className="min-w-96">
56+
<h1 className="text-2xl font-bold text-center mb-4">Hook Form</h1>
57+
58+
<Form onSubmit={handleSubmit(handleFormSubmit)}>
59+
{formFields.map((field) => (
60+
<FieldWrapper key={field.name} errorMessage={errors[field.name]?.message}>
61+
<Label htmlFor={field.name}>{field.label}</Label>
62+
<Input {...register(field.name)} />
63+
</FieldWrapper>
64+
))}
65+
66+
{optimisticState.map((state, index) => (
67+
<p key={index}>{state}</p>
68+
))}
69+
70+
<Button disabled={isPending}>Submit</Button>
71+
</Form>
72+
</div>
73+
)
74+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client'
2+
3+
import { useActionState, useOptimistic, useState, type FC } from 'react'
4+
5+
import { formSchema } from '@/constants/validation-schema'
6+
import { formFields } from '@/constants/form-fields'
7+
import { FormSchema } from '@/types/form-types'
8+
9+
import { FieldWrapper } from '../FieldWrapper/FieldWrapper'
10+
import { Form } from '../Form/Form'
11+
import { Input } from '../Input/Input'
12+
import { Label } from '../Label/Label'
13+
import { Button } from '../Button/Button'
14+
import { slowFormRequest } from '@/utils/slow-form-request'
15+
16+
type ErrorsState = Partial<{
17+
[key in keyof FormSchema]: string[]
18+
}>
19+
20+
export const ReactForm: FC = () => {
21+
const [errors, setErrors] = useState<ErrorsState>()
22+
23+
const [optimisticState, addOptimisticState] = useOptimistic<string[]>([])
24+
25+
const handleFormSubmit = async (previousState: FormSchema, formData: FormData) => {
26+
try {
27+
addOptimisticState(['Submitting form...'])
28+
29+
const formDataEntries = Object.fromEntries(formData.entries())
30+
const result = formSchema.safeParse(formDataEntries)
31+
32+
if (result.error) {
33+
addOptimisticState(['Form submission failed!'])
34+
35+
setErrors(result.error.flatten().fieldErrors)
36+
37+
// Keep the optimistic state for 1 second
38+
await new Promise((resolve) => setTimeout(resolve, 1000))
39+
40+
return {
41+
firstName: '',
42+
lastName: '',
43+
note: '',
44+
inviteCode: '',
45+
}
46+
}
47+
48+
const submitResult = await slowFormRequest(result.data)
49+
50+
addOptimisticState(['Form submitted successfully!'])
51+
// Keep the optimistic state for 1 second
52+
await new Promise((resolve) => setTimeout(resolve, 1000))
53+
54+
return {
55+
firstName: submitResult.firstName,
56+
lastName: submitResult.lastName,
57+
note: submitResult.note,
58+
inviteCode: submitResult.inviteCode,
59+
}
60+
} catch (error) {
61+
addOptimisticState([`Form submission failed! ${JSON.stringify(error)}`])
62+
63+
return {
64+
firstName: '',
65+
lastName: '',
66+
note: '',
67+
inviteCode: '',
68+
}
69+
}
70+
}
71+
72+
const [state, formAction, isPending] = useActionState(handleFormSubmit, {
73+
firstName: '',
74+
lastName: '',
75+
note: '',
76+
inviteCode: '',
77+
})
78+
79+
return (
80+
<div className="min-w-96">
81+
<h1 className="text-2xl font-bold text-center mb-4">React Form</h1>
82+
83+
<Form action={formAction}>
84+
{formFields.map((field) => (
85+
<FieldWrapper key={field.name} errorMessage={errors?.[field.name]?.[0]}>
86+
<Label htmlFor={field.name}>{field.label}</Label>
87+
<Input name={field.name} />
88+
</FieldWrapper>
89+
))}
90+
91+
{optimisticState.map((message) => (
92+
<div key={message}>{message}</div>
93+
))}
94+
95+
<Button disabled={isPending}>Submit</Button>
96+
</Form>
97+
</div>
98+
)
99+
}

src/constants/form-fields.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { FormSchema } from '@/types/form-types'
2+
3+
export const formFields: { label: string; name: keyof FormSchema }[] = [
4+
{
5+
label: 'First Name',
6+
name: 'firstName',
7+
},
8+
{
9+
label: 'Last Name',
10+
name: 'lastName',
11+
},
12+
{
13+
label: 'Note',
14+
name: 'note',
15+
},
16+
{
17+
label: 'Invite Code',
18+
name: 'inviteCode',
19+
},
20+
]

0 commit comments

Comments
 (0)