From b5e2582b343384a348a2010d7959104fdd07e07d Mon Sep 17 00:00:00 2001 From: Jannis Jorre Date: Mon, 3 Jul 2023 14:10:39 +0200 Subject: [PATCH 1/3] docs: add useFetcher examples for react-router --- examples/react-router/src/App.tsx | 21 +++- .../react-router/src/login_useFetcher.tsx | 86 +++++++++++++ .../react-router/src/signup_useFetcher.tsx | 117 ++++++++++++++++++ .../react-router/src/todos_useFetcher.tsx | 106 ++++++++++++++++ 4 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 examples/react-router/src/login_useFetcher.tsx create mode 100644 examples/react-router/src/signup_useFetcher.tsx create mode 100644 examples/react-router/src/todos_useFetcher.tsx diff --git a/examples/react-router/src/App.tsx b/examples/react-router/src/App.tsx index cc8d5eba..4bfeaa08 100644 --- a/examples/react-router/src/App.tsx +++ b/examples/react-router/src/App.tsx @@ -11,8 +11,20 @@ const router = createBrowserRouter( createRoutesFromElements( import('./login')} /> + import('./login_useFetcher')} + /> import('./todos')} /> + import('./todos_useFetcher')} + /> import('./signup')} /> + import('./signup_useFetcher')} + /> , ), ); @@ -30,13 +42,16 @@ function Example() {
  • - Login + Login ( + with useFetcher)
  • - Todo list + Todo list ( + with useFetcher)
  • - Signup + Signup ( + with useFetcher)
diff --git a/examples/react-router/src/login_useFetcher.tsx b/examples/react-router/src/login_useFetcher.tsx new file mode 100644 index 00000000..d0ca28cf --- /dev/null +++ b/examples/react-router/src/login_useFetcher.tsx @@ -0,0 +1,86 @@ +import type { Submission } from '@conform-to/react'; +import { useForm, parse, validateConstraint } from '@conform-to/react'; +import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; +import { json, redirect } from 'react-router-dom'; + +interface Login { + email: string; + password: string; + remember: string; +} + +async function isAuthenticated(email: string, password: string) { + return new Promise((resolve) => { + resolve(email === 'conform@example.com' && password === '12345'); + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const submission = parse(formData); + + if ( + !(await isAuthenticated( + submission.payload.email, + submission.payload.password, + )) + ) { + return json({ + ...submission, + // '' denote the root which is treated as form error + error: { '': 'Invalid credential' }, + }); + } + + return redirect('/'); +} + +export function Component() { + const fetcher = useFetcher(); + const [form, { email, password }] = useForm({ + lastSubmission: fetcher.data, + shouldValidate: 'onBlur', + onValidate(context) { + return validateConstraint(context); + }, + }); + + return ( + +
{form.error}
+ + + +
+ +
+ ); +} diff --git a/examples/react-router/src/signup_useFetcher.tsx b/examples/react-router/src/signup_useFetcher.tsx new file mode 100644 index 00000000..63fdf93f --- /dev/null +++ b/examples/react-router/src/signup_useFetcher.tsx @@ -0,0 +1,117 @@ +import type { Submission } from '@conform-to/react'; +import { useForm } from '@conform-to/react'; +import { parse, refine } from '@conform-to/zod'; +import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; +import { json } from 'react-router-dom'; +import { z } from 'zod'; + +// Instead of sharing a schema, prepare a schema creator +function createSchema( + intent: string, + constarint: { + // isUsernameUnique is only defined on the server + isUsernameUnique?: (username: string) => Promise; + } = {}, +) { + return z + .object({ + username: z + .string() + .min(1, 'Username is required') + .regex( + /^[a-zA-Z0-9]+$/, + 'Invalid username: only letters or numbers are allowed', + ) + // We use `.superRefine` instead of `.refine` for better control + .superRefine((username, ctx) => + refine(ctx, { + validate: () => constarint.isUsernameUnique?.(username), + when: intent === 'submit' || intent === 'validate/username', + message: 'Username is already used', + }), + ), + password: z.string().min(1, 'Password is required'), + confirmPassword: z.string().min(1, 'Confirm Password is required'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Password does not match', + path: ['confirmPassword'], + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const submission = await parse(formData, { + schema: (intent) => + // create the zod schema with the intent and constraint + createSchema(intent, { + isUsernameUnique(username) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(username !== 'admin'); + }, Math.random() * 300); + }); + }, + }), + async: true, + }); + + if (!submission.value || submission.intent !== 'submit') { + return json({ + ...submission, + payload: { + username: submission.payload.username, + }, + }); + } + + throw new Error('Not implemented'); +} + +export function Component() { + const fetcher = useFetcher(); + const [form, { username, password, confirmPassword }] = useForm({ + lastSubmission: fetcher.data, + onValidate({ formData }) { + return parse(formData, { + // Create the schema without any constraint defined + schema: (intent) => createSchema(intent), + }); + }, + }); + + return ( + +
{form.error}
+ + + +
+ +
+ ); +} diff --git a/examples/react-router/src/todos_useFetcher.tsx b/examples/react-router/src/todos_useFetcher.tsx new file mode 100644 index 00000000..3ff96d18 --- /dev/null +++ b/examples/react-router/src/todos_useFetcher.tsx @@ -0,0 +1,106 @@ +import type { FieldsetConfig, Submission } from '@conform-to/react'; +import { useForm, useFieldset, useFieldList, list } from '@conform-to/react'; +import { parse } from '@conform-to/zod'; +import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; +import { json } from 'react-router-dom'; +import { useRef } from 'react'; +import { z } from 'zod'; + +const taskSchema = z.object({ + content: z.string().min(1, 'Content is required'), + completed: z.string().transform((value) => value === 'yes'), +}); + +const todosSchema = z.object({ + title: z.string().min(1, 'Title is required'), + tasks: z.array(taskSchema).min(1), +}); + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const submission = parse(formData, { + schema: todosSchema, + }); + + if (!submission.value || submission.intent !== 'submit') { + return json(submission); + } + + throw new Error('Not implemented'); +} + +export function Component() { + const fetcher = useFetcher(); + const [form, { title, tasks }] = useForm({ + lastSubmission: fetcher.data, + onValidate({ formData }) { + return parse(formData, { schema: todosSchema }); + }, + }); + const taskList = useFieldList(form.ref, tasks); + + return ( + +
{form.error}
+
+ + +
{title.error}
+
+ {taskList.map((task, index) => ( +

+ + + + +

+ ))} + +
+ +
+ ); +} + +interface TaskFieldsetProps extends FieldsetConfig> { + title: string; +} + +function TaskFieldset({ title, ...config }: TaskFieldsetProps) { + const ref = useRef(null); + const { content, completed } = useFieldset(ref, config); + + return ( +
+
+ + +
{content.error}
+
+
+ +
+
+ ); +} From 43e243afa4c2fba828a689f1dc7f17221715ac47 Mon Sep 17 00:00:00 2001 From: Jannis Jorre Date: Mon, 3 Jul 2023 14:28:00 +0200 Subject: [PATCH 2/3] docs: add useFetcher examples for remix --- examples/remix/app/root.tsx | 9 +- .../remix/app/routes/login_useFetcher.tsx | 118 ++++++++++++++++++ .../remix/app/routes/signup_useFetcher.tsx | 115 +++++++++++++++++ .../remix/app/routes/todos_useFetcher.tsx | 117 +++++++++++++++++ 4 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 examples/remix/app/routes/login_useFetcher.tsx create mode 100644 examples/remix/app/routes/signup_useFetcher.tsx create mode 100644 examples/remix/app/routes/todos_useFetcher.tsx diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 98fa31c1..336b76d2 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -39,13 +39,16 @@ export default function App() {
  • - Login + Login ( + with useFetcher)
  • - Todo list + Todo list ( + with useFetcher)
  • - Signup + Signup ( + with useFetcher)
diff --git a/examples/remix/app/routes/login_useFetcher.tsx b/examples/remix/app/routes/login_useFetcher.tsx new file mode 100644 index 00000000..fd56d552 --- /dev/null +++ b/examples/remix/app/routes/login_useFetcher.tsx @@ -0,0 +1,118 @@ +import { conform, parse, Submission, useForm } from '@conform-to/react'; +import type { ActionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { useActionData, useFetcher } from '@remix-run/react'; + +interface SignupForm { + email: string; + password: string; + confirmPassword: string; +} + +function parseFormData(formData: FormData) { + return parse(formData, { + resolve({ email, password, confirmPassword }) { + const error: Record = {}; + + if (!email) { + error.email = 'Email is required'; + } else if (!email.includes('@')) { + error.email = 'Email is invalid'; + } + + if (!password) { + error.password = 'Password is required'; + } + + if (!confirmPassword) { + error.confirmPassword = 'Confirm password is required'; + } else if (confirmPassword !== password) { + error.confirmPassword = 'Password does not match'; + } + + if (error.email || error.password || error.confirmPassword) { + return { error }; + } + + // Return the value only if no error + return { + value: { + email, + password, + confirmPassword, + }, + }; + }, + }); +} + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const submission = parseFormData(formData); + + /** + * Signup only when the user click on the submit button and no error found + */ + if (!submission.value || submission.intent !== 'submit') { + // Always sends the submission state back to client until the user is signed up + return json({ + ...submission, + payload: { + // Never send the password back to client + email: submission.payload.email, + }, + }); + } + + throw new Error('Not implemented'); +} + +export default function Signup() { + const fetcher = useFetcher(); + const actionData = useActionData(); + // Last submission returned by the server + // Fallback to useActionData in case JavaScript is disabled in the browser + const lastSubmission = fetcher.data ?? actionData; + const [form, { email, password, confirmPassword }] = useForm({ + // Sync the result of last submission + lastSubmission, + shouldValidate: 'onBlur', + + // Reuse the validation logic on the client + onValidate({ formData }) { + return parseFormData(formData); + }, + }); + + return ( + +
{form.error}
+
+ + +
{email.error}
+
+
+ + +
{password.error}
+
+
+ + +
{confirmPassword.error}
+
+
+ +
+ ); +} diff --git a/examples/remix/app/routes/signup_useFetcher.tsx b/examples/remix/app/routes/signup_useFetcher.tsx new file mode 100644 index 00000000..7166d1ed --- /dev/null +++ b/examples/remix/app/routes/signup_useFetcher.tsx @@ -0,0 +1,115 @@ +import { conform, useForm } from '@conform-to/react'; +import { parse, refine } from '@conform-to/zod'; +import type { ActionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { useActionData, useFetcher } from '@remix-run/react'; +import { z } from 'zod'; + +// Instead of sharing a schema, prepare a schema creator +function createSchema( + intent: string, + constarint: { + // isUsernameUnique is only defined on the server + isUsernameUnique?: (username: string) => Promise; + } = {}, +) { + return z + .object({ + username: z + .string() + .min(1, 'Username is required') + .regex( + /^[a-zA-Z0-9]+$/, + 'Invalid username: only letters or numbers are allowed', + ) + // We use `.superRefine` instead of `.refine` for better control + .superRefine((username, ctx) => + refine(ctx, { + validate: () => constarint.isUsernameUnique?.(username), + when: intent === 'submit' || intent === 'validate/username', + message: 'Username is already used', + }), + ), + password: z.string().min(1, 'Password is required'), + confirmPassword: z.string().min(1, 'Confirm Password is required'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Password does not match', + path: ['confirmPassword'], + }); +} + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const submission = await parse(formData, { + schema: (intent) => + // create the zod schema with the intent and constraint + createSchema(intent, { + isUsernameUnique(username) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(username !== 'admin'); + }, Math.random() * 300); + }); + }, + }), + async: true, + }); + + if (!submission.value || submission.intent !== 'submit') { + return json({ + ...submission, + payload: { + username: submission.payload.username, + }, + }); + } + + throw new Error('Not implemented'); +} + +export default function Signup() { + const fetcher = useFetcher(); + const actionData = useActionData(); + const [form, { username, password, confirmPassword }] = useForm({ + lastSubmission: fetcher.data ?? actionData, + onValidate({ formData }) { + return parse(formData, { + // Create the schema without any constraint defined + schema: (intent) => createSchema(intent), + }); + }, + }); + + return ( + +
{form.error}
+ + + +
+ +
+ ); +} diff --git a/examples/remix/app/routes/todos_useFetcher.tsx b/examples/remix/app/routes/todos_useFetcher.tsx new file mode 100644 index 00000000..3b018cd5 --- /dev/null +++ b/examples/remix/app/routes/todos_useFetcher.tsx @@ -0,0 +1,117 @@ +import type { FieldsetConfig } from '@conform-to/react'; +import { + useForm, + useFieldset, + useFieldList, + conform, + list, +} from '@conform-to/react'; +import { parse } from '@conform-to/zod'; +import type { ActionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { useActionData, useFetcher } from '@remix-run/react'; +import { useRef } from 'react'; +import { z } from 'zod'; + +const taskSchema = z.object({ + content: z.string().min(1, 'Content is required'), + completed: z.string().transform((value) => value === 'yes'), +}); + +const todosSchema = z.object({ + title: z.string().min(1, 'Title is required'), + tasks: z.array(taskSchema).min(1), +}); + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const submission = parse(formData, { + schema: todosSchema, + }); + + if (!submission.value || submission.intent !== 'submit') { + return json(submission); + } + + throw new Error('Not implemented'); +} + +export default function TodoForm() { + const fetcher = useFetcher(); + const actionData = useActionData(); + const [form, { title, tasks }] = useForm({ + lastSubmission: fetcher.data ?? actionData, + onValidate({ formData }) { + return parse(formData, { schema: todosSchema }); + }, + }); + const taskList = useFieldList(form.ref, tasks); + + return ( + +
{form.error}
+
+ + +
{title.error}
+
+ {taskList.map((task, index) => ( +

+ + + + +

+ ))} + +
+ +
+ ); +} + +interface TaskFieldsetProps extends FieldsetConfig> { + title: string; +} + +function TaskFieldset({ title, ...config }: TaskFieldsetProps) { + const ref = useRef(null); + const { content, completed } = useFieldset(ref, config); + + return ( +
+
+ + +
{content.error}
+
+
+ +
+
+ ); +} From be5125bf89d4394783dde0e39af6c53f11ef8838 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sun, 9 Jul 2023 00:08:41 +0200 Subject: [PATCH 3/3] showcase usefetcher with login form only --- examples/react-router/src/App.tsx | 21 +--- ...login_useFetcher.tsx => login-fetcher.tsx} | 6 +- examples/react-router/src/login.tsx | 5 +- .../react-router/src/signup_useFetcher.tsx | 117 ------------------ .../react-router/src/todos_useFetcher.tsx | 106 ---------------- examples/remix/app/root.tsx | 8 +- ...login_useFetcher.tsx => login-fetcher.tsx} | 9 +- .../remix/app/routes/signup_useFetcher.tsx | 115 ----------------- .../remix/app/routes/todos_useFetcher.tsx | 117 ------------------ 9 files changed, 15 insertions(+), 489 deletions(-) rename examples/react-router/src/{login_useFetcher.tsx => login-fetcher.tsx} (92%) delete mode 100644 examples/react-router/src/signup_useFetcher.tsx delete mode 100644 examples/react-router/src/todos_useFetcher.tsx rename examples/remix/app/routes/{login_useFetcher.tsx => login-fetcher.tsx} (88%) delete mode 100644 examples/remix/app/routes/signup_useFetcher.tsx delete mode 100644 examples/remix/app/routes/todos_useFetcher.tsx diff --git a/examples/react-router/src/App.tsx b/examples/react-router/src/App.tsx index 4bfeaa08..4ef045d0 100644 --- a/examples/react-router/src/App.tsx +++ b/examples/react-router/src/App.tsx @@ -11,20 +11,9 @@ const router = createBrowserRouter( createRoutesFromElements( import('./login')} /> - import('./login_useFetcher')} - /> + import('./login-fetcher')} /> import('./todos')} /> - import('./todos_useFetcher')} - /> import('./signup')} /> - import('./signup_useFetcher')} - /> , ), ); @@ -43,15 +32,13 @@ function Example() {
  • Login ( - with useFetcher) + with useFetcher)
  • - Todo list ( - with useFetcher) + Todo list
  • - Signup ( - with useFetcher) + Signup
diff --git a/examples/react-router/src/login_useFetcher.tsx b/examples/react-router/src/login-fetcher.tsx similarity index 92% rename from examples/react-router/src/login_useFetcher.tsx rename to examples/react-router/src/login-fetcher.tsx index d0ca28cf..524c823c 100644 --- a/examples/react-router/src/login_useFetcher.tsx +++ b/examples/react-router/src/login-fetcher.tsx @@ -1,7 +1,7 @@ import type { Submission } from '@conform-to/react'; import { useForm, parse, validateConstraint } from '@conform-to/react'; -import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; -import { json, redirect } from 'react-router-dom'; +import type { ActionFunctionArgs } from 'react-router-dom'; +import { useFetcher, json, redirect } from 'react-router-dom'; interface Login { email: string; @@ -39,7 +39,7 @@ export function Component() { const fetcher = useFetcher(); const [form, { email, password }] = useForm({ lastSubmission: fetcher.data, - shouldValidate: 'onBlur', + shouldRevalidate: 'onBlur', onValidate(context) { return validateConstraint(context); }, diff --git a/examples/react-router/src/login.tsx b/examples/react-router/src/login.tsx index 08a90b49..6041b8d0 100644 --- a/examples/react-router/src/login.tsx +++ b/examples/react-router/src/login.tsx @@ -1,8 +1,7 @@ import type { Submission } from '@conform-to/react'; import { useForm, parse, validateConstraint } from '@conform-to/react'; import type { ActionFunctionArgs } from 'react-router-dom'; -import { Form, useActionData } from 'react-router-dom'; -import { json, redirect } from 'react-router-dom'; +import { Form, useActionData, json, redirect } from 'react-router-dom'; interface Login { email: string; @@ -40,7 +39,7 @@ export function Component() { const lastSubmission = useActionData() as Submission; const [form, { email, password }] = useForm({ lastSubmission, - shouldValidate: 'onBlur', + shouldRevalidate: 'onBlur', onValidate(context) { return validateConstraint(context); }, diff --git a/examples/react-router/src/signup_useFetcher.tsx b/examples/react-router/src/signup_useFetcher.tsx deleted file mode 100644 index 63fdf93f..00000000 --- a/examples/react-router/src/signup_useFetcher.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { Submission } from '@conform-to/react'; -import { useForm } from '@conform-to/react'; -import { parse, refine } from '@conform-to/zod'; -import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; -import { json } from 'react-router-dom'; -import { z } from 'zod'; - -// Instead of sharing a schema, prepare a schema creator -function createSchema( - intent: string, - constarint: { - // isUsernameUnique is only defined on the server - isUsernameUnique?: (username: string) => Promise; - } = {}, -) { - return z - .object({ - username: z - .string() - .min(1, 'Username is required') - .regex( - /^[a-zA-Z0-9]+$/, - 'Invalid username: only letters or numbers are allowed', - ) - // We use `.superRefine` instead of `.refine` for better control - .superRefine((username, ctx) => - refine(ctx, { - validate: () => constarint.isUsernameUnique?.(username), - when: intent === 'submit' || intent === 'validate/username', - message: 'Username is already used', - }), - ), - password: z.string().min(1, 'Password is required'), - confirmPassword: z.string().min(1, 'Confirm Password is required'), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Password does not match', - path: ['confirmPassword'], - }); -} - -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const submission = await parse(formData, { - schema: (intent) => - // create the zod schema with the intent and constraint - createSchema(intent, { - isUsernameUnique(username) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(username !== 'admin'); - }, Math.random() * 300); - }); - }, - }), - async: true, - }); - - if (!submission.value || submission.intent !== 'submit') { - return json({ - ...submission, - payload: { - username: submission.payload.username, - }, - }); - } - - throw new Error('Not implemented'); -} - -export function Component() { - const fetcher = useFetcher(); - const [form, { username, password, confirmPassword }] = useForm({ - lastSubmission: fetcher.data, - onValidate({ formData }) { - return parse(formData, { - // Create the schema without any constraint defined - schema: (intent) => createSchema(intent), - }); - }, - }); - - return ( - -
{form.error}
- - - -
- -
- ); -} diff --git a/examples/react-router/src/todos_useFetcher.tsx b/examples/react-router/src/todos_useFetcher.tsx deleted file mode 100644 index 3ff96d18..00000000 --- a/examples/react-router/src/todos_useFetcher.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { FieldsetConfig, Submission } from '@conform-to/react'; -import { useForm, useFieldset, useFieldList, list } from '@conform-to/react'; -import { parse } from '@conform-to/zod'; -import { ActionFunctionArgs, useFetcher } from 'react-router-dom'; -import { json } from 'react-router-dom'; -import { useRef } from 'react'; -import { z } from 'zod'; - -const taskSchema = z.object({ - content: z.string().min(1, 'Content is required'), - completed: z.string().transform((value) => value === 'yes'), -}); - -const todosSchema = z.object({ - title: z.string().min(1, 'Title is required'), - tasks: z.array(taskSchema).min(1), -}); - -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const submission = parse(formData, { - schema: todosSchema, - }); - - if (!submission.value || submission.intent !== 'submit') { - return json(submission); - } - - throw new Error('Not implemented'); -} - -export function Component() { - const fetcher = useFetcher(); - const [form, { title, tasks }] = useForm({ - lastSubmission: fetcher.data, - onValidate({ formData }) { - return parse(formData, { schema: todosSchema }); - }, - }); - const taskList = useFieldList(form.ref, tasks); - - return ( - -
{form.error}
-
- - -
{title.error}
-
- {taskList.map((task, index) => ( -

- - - - -

- ))} - -
- -
- ); -} - -interface TaskFieldsetProps extends FieldsetConfig> { - title: string; -} - -function TaskFieldset({ title, ...config }: TaskFieldsetProps) { - const ref = useRef(null); - const { content, completed } = useFieldset(ref, config); - - return ( -
-
- - -
{content.error}
-
-
- -
-
- ); -} diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 336b76d2..c8e2897d 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -40,15 +40,13 @@ export default function App() {
  • Login ( - with useFetcher) + with useFetcher)
  • - Todo list ( - with useFetcher) + Todo list
  • - Signup ( - with useFetcher) + Signup
diff --git a/examples/remix/app/routes/login_useFetcher.tsx b/examples/remix/app/routes/login-fetcher.tsx similarity index 88% rename from examples/remix/app/routes/login_useFetcher.tsx rename to examples/remix/app/routes/login-fetcher.tsx index fd56d552..e1a5c34f 100644 --- a/examples/remix/app/routes/login_useFetcher.tsx +++ b/examples/remix/app/routes/login-fetcher.tsx @@ -1,7 +1,7 @@ -import { conform, parse, Submission, useForm } from '@conform-to/react'; +import { conform, parse, useForm } from '@conform-to/react'; import type { ActionArgs } from '@remix-run/node'; import { json } from '@remix-run/node'; -import { useActionData, useFetcher } from '@remix-run/react'; +import { useFetcher } from '@remix-run/react'; interface SignupForm { email: string; @@ -69,14 +69,11 @@ export async function action({ request }: ActionArgs) { export default function Signup() { const fetcher = useFetcher(); - const actionData = useActionData(); // Last submission returned by the server - // Fallback to useActionData in case JavaScript is disabled in the browser - const lastSubmission = fetcher.data ?? actionData; + const lastSubmission = fetcher.data; const [form, { email, password, confirmPassword }] = useForm({ // Sync the result of last submission lastSubmission, - shouldValidate: 'onBlur', // Reuse the validation logic on the client onValidate({ formData }) { diff --git a/examples/remix/app/routes/signup_useFetcher.tsx b/examples/remix/app/routes/signup_useFetcher.tsx deleted file mode 100644 index 7166d1ed..00000000 --- a/examples/remix/app/routes/signup_useFetcher.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { conform, useForm } from '@conform-to/react'; -import { parse, refine } from '@conform-to/zod'; -import type { ActionArgs } from '@remix-run/node'; -import { json } from '@remix-run/node'; -import { useActionData, useFetcher } from '@remix-run/react'; -import { z } from 'zod'; - -// Instead of sharing a schema, prepare a schema creator -function createSchema( - intent: string, - constarint: { - // isUsernameUnique is only defined on the server - isUsernameUnique?: (username: string) => Promise; - } = {}, -) { - return z - .object({ - username: z - .string() - .min(1, 'Username is required') - .regex( - /^[a-zA-Z0-9]+$/, - 'Invalid username: only letters or numbers are allowed', - ) - // We use `.superRefine` instead of `.refine` for better control - .superRefine((username, ctx) => - refine(ctx, { - validate: () => constarint.isUsernameUnique?.(username), - when: intent === 'submit' || intent === 'validate/username', - message: 'Username is already used', - }), - ), - password: z.string().min(1, 'Password is required'), - confirmPassword: z.string().min(1, 'Confirm Password is required'), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Password does not match', - path: ['confirmPassword'], - }); -} - -export async function action({ request }: ActionArgs) { - const formData = await request.formData(); - const submission = await parse(formData, { - schema: (intent) => - // create the zod schema with the intent and constraint - createSchema(intent, { - isUsernameUnique(username) { - return new Promise((resolve) => { - setTimeout(() => { - resolve(username !== 'admin'); - }, Math.random() * 300); - }); - }, - }), - async: true, - }); - - if (!submission.value || submission.intent !== 'submit') { - return json({ - ...submission, - payload: { - username: submission.payload.username, - }, - }); - } - - throw new Error('Not implemented'); -} - -export default function Signup() { - const fetcher = useFetcher(); - const actionData = useActionData(); - const [form, { username, password, confirmPassword }] = useForm({ - lastSubmission: fetcher.data ?? actionData, - onValidate({ formData }) { - return parse(formData, { - // Create the schema without any constraint defined - schema: (intent) => createSchema(intent), - }); - }, - }); - - return ( - -
{form.error}
- - - -
- -
- ); -} diff --git a/examples/remix/app/routes/todos_useFetcher.tsx b/examples/remix/app/routes/todos_useFetcher.tsx deleted file mode 100644 index 3b018cd5..00000000 --- a/examples/remix/app/routes/todos_useFetcher.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { FieldsetConfig } from '@conform-to/react'; -import { - useForm, - useFieldset, - useFieldList, - conform, - list, -} from '@conform-to/react'; -import { parse } from '@conform-to/zod'; -import type { ActionArgs } from '@remix-run/node'; -import { json } from '@remix-run/node'; -import { useActionData, useFetcher } from '@remix-run/react'; -import { useRef } from 'react'; -import { z } from 'zod'; - -const taskSchema = z.object({ - content: z.string().min(1, 'Content is required'), - completed: z.string().transform((value) => value === 'yes'), -}); - -const todosSchema = z.object({ - title: z.string().min(1, 'Title is required'), - tasks: z.array(taskSchema).min(1), -}); - -export async function action({ request }: ActionArgs) { - const formData = await request.formData(); - const submission = parse(formData, { - schema: todosSchema, - }); - - if (!submission.value || submission.intent !== 'submit') { - return json(submission); - } - - throw new Error('Not implemented'); -} - -export default function TodoForm() { - const fetcher = useFetcher(); - const actionData = useActionData(); - const [form, { title, tasks }] = useForm({ - lastSubmission: fetcher.data ?? actionData, - onValidate({ formData }) { - return parse(formData, { schema: todosSchema }); - }, - }); - const taskList = useFieldList(form.ref, tasks); - - return ( - -
{form.error}
-
- - -
{title.error}
-
- {taskList.map((task, index) => ( -

- - - - -

- ))} - -
- -
- ); -} - -interface TaskFieldsetProps extends FieldsetConfig> { - title: string; -} - -function TaskFieldset({ title, ...config }: TaskFieldsetProps) { - const ref = useRef(null); - const { content, completed } = useFieldset(ref, config); - - return ( -
-
- - -
{content.error}
-
-
- -
-
- ); -}