├── .eslintrc.json ├── .gitignore ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── _components │ │ ├── Header.tsx │ │ └── SubmitButton.tsx │ ├── _lib │ │ └── utils.ts │ ├── _schemas │ │ └── deal.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── react-hook-form │ │ ├── _actions │ │ │ └── formHandler.ts │ │ ├── _components │ │ │ └── DealForm.tsx │ │ └── page.tsx │ ├── server-action │ │ ├── _actions │ │ │ └── formHandler.ts │ │ ├── _components │ │ │ └── DealForm.tsx │ │ └── page.tsx │ ├── types.ts │ ├── use-form-state-with-client-validation │ │ ├── _actions │ │ │ └── formHandler.ts │ │ ├── _components │ │ │ └── DealForm.tsx │ │ └── page.tsx │ └── use-form-state │ │ ├── _actions │ │ └── formHandler.ts │ │ ├── _components │ │ └── DealForm.tsx │ │ └── page.tsx └── utils │ └── forms.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project to demo multiple ways to handle forms including: 2 | 3 | - front-end and back-end validation with Zod 4 | - server actions 5 | - `useFormState` 6 | - `useFormStatus` 7 | - `react-hook-form` 8 | 9 | Watch the full tutorial 👇 10 | 11 | [![I Was Wrong (1)](https://github.com/user-attachments/assets/d684830d-4496-42df-9eb5-9ac60d09d2f9) 12 | ](https://youtu.be/TSW0Ybxs_bE) 13 | 14 | ## Getting Started 15 | 16 | First, run the development server: 17 | 18 | ```bash 19 | npm run dev 20 | # or 21 | yarn dev 22 | # or 23 | pnpm dev 24 | # or 25 | bun dev 26 | ``` 27 | 28 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 29 | 30 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 31 | 32 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 33 | 34 | ## Learn More 35 | 36 | To learn more about Next.js, take a look at the following resources: 37 | 38 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 39 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 40 | 41 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 42 | 43 | ## Deploy on Vercel 44 | 45 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 46 | 47 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 48 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-form-validation", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.9.0", 13 | "next": "14.2.5", 14 | "react": "^18", 15 | "react-dom": "^18", 16 | "react-hook-form": "^7.52.1", 17 | "react-hot-toast": "^2.4.1", 18 | "zod": "^3.23.8" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20", 22 | "@types/react": "^18", 23 | "@types/react-dom": "^18", 24 | "eslint": "^8", 25 | "eslint-config-next": "14.2.5", 26 | "postcss": "^8", 27 | "tailwindcss": "^3.4.1", 28 | "typescript": "^5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/_components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface HeaderProps { 4 | heading: string; 5 | description?: string; 6 | } 7 | 8 | export default function Header({ heading, description }: HeaderProps) { 9 | return ( 10 |
11 |

{heading}

12 | {description &&

{description}

} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/_components/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useFormStatus } from 'react-dom'; 3 | 4 | export default function SubmitButton() { 5 | const { pending, data, method, action } = useFormStatus(); 6 | 7 | return ( 8 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/_lib/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)); 3 | -------------------------------------------------------------------------------- /src/app/_schemas/deal.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const dealSchema = z.object({ 4 | name: z.string({ message: 'Name is required' }).min(1, 'Name is required'), 5 | link: z 6 | .string({ message: 'Link is required' }) 7 | .url('Link must be a valid URL'), 8 | couponCode: z 9 | .string({ message: 'Coupon code is required' }) 10 | .min(5, 'Coupon code must be at least 5 characters'), 11 | discount: z.coerce 12 | .number({ message: 'Discount percentage is required' }) 13 | .min(1, 'Discount percentage must be 1 or greater') 14 | .max(100), 15 | }); 16 | 17 | export type Deal = z.infer; 18 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesqquick/nextjs-form-validation/5f08ee3def343e2f3019918e9e928c04cb6f8c98/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | @layer utilities { 30 | .text-balance { 31 | text-wrap: balance; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import './globals.css'; 4 | import { Toaster } from 'react-hot-toast'; 5 | import Link from 'next/link'; 6 | 7 | const inter = Inter({ subsets: ['latin'] }); 8 | 9 | export const metadata: Metadata = { 10 | title: 'Create Next App', 11 | description: 'Generated by create next app', 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 48 |
{children}
49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Header from './_components/Header'; 3 | 4 | export default function Home() { 5 | return ( 6 | <> 7 |
11 |

12 | There are lots of different ways to handle forms and form validation in 13 | Next.js. This demo shows a few different options using multiple versions 14 | of form actions, custom client-side validation, and client-side 15 | validation with react-hook-form. 16 |

17 | Github Repo 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/react-hook-form/_actions/formHandler.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sleep } from '@/app/_lib/utils'; 4 | import { Deal, dealSchema } from '../../_schemas/deal'; 5 | import { DealFormState } from '@/app/types'; 6 | 7 | export const formHandlerAction = async ( 8 | data: unknown 9 | ): Promise => { 10 | //uncomment to easily view loading state in submit button 11 | //await sleep(1000); 12 | const validated = dealSchema.safeParse(data); 13 | 14 | if (!validated.success) { 15 | const errors = validated.error.issues.reduce( 16 | (acc: { [key: string]: string }, issue) => { 17 | acc[issue.path[0]] = issue.message; 18 | return acc; 19 | }, 20 | {} 21 | ); 22 | return { 23 | errors, 24 | }; 25 | } else { 26 | return { successMsg: 'Deal added successfully!', data: {} }; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/react-hook-form/_components/DealForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useForm } from 'react-hook-form'; 3 | import toast from 'react-hot-toast'; 4 | import { Deal, dealSchema } from '@/app/_schemas/deal'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { formHandlerAction } from '../_actions/formHandler'; 7 | 8 | export default function DealForm() { 9 | const { 10 | register, 11 | handleSubmit, 12 | reset, 13 | formState: { errors, isSubmitting }, 14 | } = useForm({ 15 | resolver: zodResolver(dealSchema), 16 | defaultValues: { 17 | name: '', 18 | link: '', 19 | couponCode: '', 20 | discount: 10, 21 | }, 22 | mode: 'onBlur', 23 | }); 24 | 25 | const onSubmit = async (deal: Deal) => { 26 | const { successMsg } = await formHandlerAction(deal); 27 | if (successMsg) { 28 | toast.success(successMsg); 29 | reset(); 30 | } 31 | }; 32 | 33 | return ( 34 |
35 |
36 |
37 | 40 | 45 |
46 | {errors?.name && ( 47 | {errors.name?.message} 48 | )} 49 |
50 |
51 |
52 | 55 | 60 |
61 | {errors?.link && ( 62 | {errors.link?.message} 63 | )} 64 |
65 |
66 |
67 | 70 | 75 |
76 | {errors?.couponCode && ( 77 | 78 | {errors.couponCode.message} 79 | 80 | )} 81 |
82 |
83 |
84 | 87 | 92 |
93 | {errors?.discount && ( 94 | {errors.discount.message} 95 | )} 96 |
97 |
98 | 104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/app/react-hook-form/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../_components/Header'; 2 | import DealForm from './_components/DealForm'; 3 | 4 | export default function ReactHookForm() { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/server-action/_actions/formHandler.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sleep } from '@/app/_lib/utils'; 4 | import { dealSchema } from '../../_schemas/deal'; 5 | import { DealFormState } from '../../types'; 6 | import { convertZodErrors } from '@/utils/forms'; 7 | 8 | export const formHandlerAction = async ( 9 | formData: FormData 10 | ): Promise => { 11 | //uncomment to easily view loading state in submit button 12 | //await sleep(1000); 13 | 14 | const unvalidatedData = { 15 | name: formData.get('name'), 16 | link: formData.get('link'), 17 | couponCode: formData.get('couponCode'), 18 | discount: formData.get('discount'), 19 | }; 20 | 21 | const validated = dealSchema.safeParse(unvalidatedData); 22 | 23 | if (!validated.success) { 24 | const errors = convertZodErrors(validated.error); 25 | return { 26 | errors, 27 | }; 28 | } else { 29 | return { successMsg: 'Deal added successfully!' }; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/server-action/_components/DealForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { StringMap } from '../../types'; 3 | import { useRef, useState } from 'react'; 4 | import toast from 'react-hot-toast'; 5 | import SubmitButton from '@/app/_components/SubmitButton'; 6 | import { formHandlerAction } from '../_actions/formHandler'; 7 | 8 | export default function DealForm() { 9 | const [errors, setErrors] = useState({}); 10 | const formRef = useRef(null); 11 | 12 | const handleFormSubmit = async (formData: FormData) => { 13 | const { errors, successMsg } = await formHandlerAction(formData); 14 | setErrors(errors || {}); 15 | if (successMsg) { 16 | toast.success(successMsg); 17 | formRef.current?.reset(); 18 | } 19 | }; 20 | 21 | return ( 22 |
23 |
24 |
25 | 28 | 35 |
36 | {errors?.name && ( 37 | {errors.name} 38 | )} 39 |
40 |
41 |
42 | 45 | 54 |
55 | {errors?.link && ( 56 | {errors.link} 57 | )} 58 |
59 |
60 |
61 | 64 | 72 |
73 | {errors?.couponCode && ( 74 | {errors.couponCode} 75 | )} 76 |
77 |
78 |
79 | 82 | 92 |
93 | {errors?.discount && ( 94 | {errors.discount} 95 | )} 96 |
97 |
98 | 99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/app/server-action/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../_components/Header'; 2 | import DealForm from './_components/DealForm'; 3 | 4 | export default function ClientAction() { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/types.ts: -------------------------------------------------------------------------------- 1 | export interface DealFormState { 2 | errors?: StringMap; 3 | successMsg?: string; 4 | data?: any; 5 | blurs?: StringToBooleanMap; 6 | } 7 | 8 | export interface StringMap { 9 | [key: string]: string; 10 | } 11 | 12 | export interface StringToBooleanMap { 13 | [key: string]: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/use-form-state-with-client-validation/_actions/formHandler.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sleep } from '@/app/_lib/utils'; 4 | import { dealSchema } from '../../_schemas/deal'; 5 | import { DealFormState } from '../../types'; 6 | import { convertZodErrors } from '@/utils/forms'; 7 | 8 | export const formHandlerAction = async ( 9 | prevState: DealFormState, 10 | formData: FormData 11 | ): Promise => { 12 | //uncomment to easily view loading state in submit button 13 | //await sleep(1000); 14 | 15 | const unvalidatedData = { 16 | name: formData.get('name'), 17 | link: formData.get('link'), 18 | couponCode: formData.get('couponCode'), 19 | discount: formData.get('discount'), 20 | }; 21 | 22 | const validated = dealSchema.safeParse(unvalidatedData); 23 | 24 | if (!validated.success) { 25 | const errors = convertZodErrors(validated.error); 26 | return { 27 | errors, 28 | data: unvalidatedData, 29 | blurs: Object.fromEntries( 30 | Object.keys(unvalidatedData).map((key) => [key, true]) 31 | ), 32 | }; 33 | } else { 34 | return { 35 | successMsg: 'Deal added successfully!', 36 | data: { 37 | name: '', 38 | link: '', 39 | couponCode: '', 40 | discount: 10, 41 | }, 42 | }; 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/app/use-form-state-with-client-validation/_components/DealForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { DealFormState, StringMap, StringToBooleanMap } from '../../types'; 3 | import { useEffect, useState } from 'react'; 4 | import toast from 'react-hot-toast'; 5 | import { Deal, dealSchema } from '@/app/_schemas/deal'; 6 | import SubmitButton from '@/app/_components/SubmitButton'; 7 | import { formHandlerAction } from '../_actions/formHandler'; 8 | import { useFormState } from 'react-dom'; 9 | import { convertZodErrors } from '@/utils/forms'; 10 | 11 | const inputNames = ['name', 'link', 'couponCode', 'discount']; 12 | const initialState: DealFormState = {}; 13 | 14 | const initialData: Deal = { 15 | name: '', 16 | link: '', 17 | couponCode: '', 18 | discount: 10, 19 | }; 20 | 21 | export default function DealForm() { 22 | const [serverState, formAction] = useFormState( 23 | formHandlerAction, 24 | initialState 25 | ); 26 | const [errors, setErrors] = useState(serverState.errors || {}); 27 | 28 | const [blurs, setBlurs] = useState( 29 | serverState.blurs || {} 30 | ); 31 | const [deal, setDeal] = useState(serverState.data || initialData); 32 | 33 | useEffect(() => { 34 | if (serverState.successMsg) { 35 | toast.success(serverState.successMsg); 36 | setBlurs({}); 37 | } else if (serverState.errors) { 38 | setAllBlurred(); 39 | } 40 | if (serverState.data) { 41 | setDeal(serverState.data); 42 | } 43 | setErrors(serverState.errors || {}); 44 | }, [serverState]); 45 | 46 | const setAllBlurred = () => { 47 | const blurred: StringToBooleanMap = {}; 48 | inputNames.forEach((name) => { 49 | blurred[name] = true; 50 | }); 51 | setBlurs(blurred); 52 | }; 53 | 54 | const handleOnBlur = (e: React.FocusEvent) => { 55 | const { name } = e.target; 56 | setBlurs((prev) => ({ ...prev, [name]: true })); 57 | }; 58 | 59 | const handleOnChange = (e: React.ChangeEvent) => { 60 | const { name, value } = e.target; 61 | setDeal((prev) => { 62 | const updatedData = { ...prev, [name]: value }; 63 | const validated = dealSchema.safeParse(updatedData); 64 | if (validated.success) { 65 | setErrors({}); 66 | } else { 67 | const errors = convertZodErrors(validated.error); 68 | setErrors(errors); 69 | } 70 | return updatedData; 71 | }); 72 | }; 73 | return ( 74 |
75 |
76 |
77 | 80 | 89 |
90 | {blurs.name && errors?.name && ( 91 | {errors.name} 92 | )} 93 |
94 |
95 |
96 | 99 | 108 |
109 | {blurs.link && errors?.link && ( 110 | {errors.link} 111 | )} 112 |
113 |
114 |
115 | 118 | 127 |
128 | {blurs.couponCode && errors?.couponCode && ( 129 | {errors.couponCode} 130 | )} 131 |
132 |
133 |
134 | 137 | 146 |
147 | {blurs.discount && errors?.discount && ( 148 | {errors.discount} 149 | )} 150 |
151 |
152 | 153 |
154 |
155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/app/use-form-state-with-client-validation/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../_components/Header'; 2 | import DealForm from './_components/DealForm'; 3 | 4 | export default function Home() { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/use-form-state/_actions/formHandler.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sleep } from '@/app/_lib/utils'; 4 | import { dealSchema } from '../../_schemas/deal'; 5 | import { DealFormState } from '../../types'; 6 | import { convertZodErrors } from '@/utils/forms'; 7 | 8 | export const formHandlerAction = async ( 9 | prevState: DealFormState, 10 | formData: FormData 11 | ): Promise => { 12 | //uncomment to easily view loading state in submit button 13 | //await sleep(1000); 14 | 15 | const unvalidatedData = { 16 | name: formData.get('name'), 17 | link: formData.get('link'), 18 | couponCode: formData.get('couponCode'), 19 | discount: formData.get('discount'), 20 | }; 21 | 22 | const validated = dealSchema.safeParse(unvalidatedData); 23 | 24 | if (!validated.success) { 25 | const errors = convertZodErrors(validated.error); 26 | return { 27 | errors, 28 | data: validated.data, 29 | }; 30 | } else { 31 | return { 32 | successMsg: 'Deal added successfully!', 33 | }; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/use-form-state/_components/DealForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useFormState } from 'react-dom'; 3 | import { DealFormState } from '../../types'; 4 | import { useEffect, useRef } from 'react'; 5 | import toast from 'react-hot-toast'; 6 | import SubmitButton from '@/app/_components/SubmitButton'; 7 | import { formHandlerAction } from '../_actions/formHandler'; 8 | 9 | const initialState: DealFormState = {}; 10 | export default function DealForm() { 11 | const [serverState, formAction] = useFormState( 12 | formHandlerAction, 13 | initialState 14 | ); 15 | const formRef = useRef(null); 16 | 17 | useEffect(() => { 18 | if (serverState.successMsg) { 19 | toast.success(serverState.successMsg); 20 | formRef.current?.reset(); 21 | } 22 | }, [serverState]); 23 | 24 | return ( 25 |
26 |
27 |
28 | 31 | 39 |
40 | {serverState.errors?.name && ( 41 | {serverState.errors.name} 42 | )} 43 |
44 |
45 |
46 | 49 | 59 |
60 | {serverState.errors?.link && ( 61 | {serverState.errors.link} 62 | )} 63 |
64 |
65 |
66 | 69 | 78 |
79 | {serverState.errors?.couponCode && ( 80 | 81 | {serverState.errors.couponCode} 82 | 83 | )} 84 |
85 |
86 |
87 | 90 | 100 |
101 | {serverState.errors?.discount && ( 102 | 103 | {serverState.errors.discount} 104 | 105 | )} 106 |
107 |
108 | 109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/app/use-form-state/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from '../_components/Header'; 2 | import DealForm from './_components/DealForm'; 3 | 4 | export default function FormState() { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/forms.ts: -------------------------------------------------------------------------------- 1 | import { StringMap } from '@/app/types'; 2 | import { ZodError } from 'zod'; 3 | 4 | export const convertZodErrors = (error: ZodError): StringMap => { 5 | return error.issues.reduce((acc: { [key: string]: string }, issue) => { 6 | acc[issue.path[0]] = issue.message; 7 | return acc; 8 | }, {}); 9 | }; 10 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------