Future Export: the useForm hook
#1014
Replies: 22 comments 121 replies
|
does this new |
|
I see that |
|
Whenever I add a file upload input to the page and try to submit the form (to a react-router action), Chrome crashes with a "Error code: RESULT_CODE_KILLED_BAD_MESSAGE". Have you tested with file uploads? I'm not 100% sure what is happening, but if I submit without going through the Conform |
|
Question about Hi, thanks for the new future API, I’m trying it out and noticed something I don’t fully understand. When I use the current API, the Here’s a minimal example comparing the two: import { getFormProps, useForm } from '@conform-to/react';
import { useForm as future_useForm } from '@conform-to/react/future';
import { unstable_coerceFormValue as coerceFormValue, parseWithValibot } from '@conform-to/valibot';
import * as v from 'valibot';
const schema = v.object({
index: v.pipe(v.number(), v.minValue(0), v.maxValue(100)),
});
export function FormWithCurrentAPI() {
const [form, fields] = useForm({
shouldValidate: "onInput",
shouldRevalidate: "onInput",
onValidate: ({ formData }) => {
return parseWithValibot(formData, { schema });
}
})
return (
<form {...getFormProps(form)}>
<div>Current API</div>
<div>{form.valid ? "valid" : "invalid"}</div>
<div>
<label htmlFor={fields.index.id}>Index</label>
<input
id={fields.index.id}
name={fields.index.name}
defaultValue={fields.index.defaultValue}
/>
<div>{fields.index.errors}</div>
</div>
</form>
)
}
export function FormWithFutureAPI() {
const { form, fields } = future_useForm({
shouldValidate: "onInput",
shouldRevalidate: "onInput",
schema: coerceFormValue(schema),
});
return (
<form {...form.props}>
<div>Future API</div>
<div>{form.valid ? "valid" : "invalid"}</div>
<div>
<label htmlFor={fields.index.id}>Index</label>
<input
id={fields.index.id}
name={fields.index.name}
defaultValue={fields.index.defaultValue}
/>
<div>{fields.index.errors}</div>
</div>
</form>
)
}Steps to reproduce
Question Is this the intended behavior of the future API? Thanks a lot! |
|
I notice that server actions feel a little complex in this new version. It's nice to be able to pass in a standard schema property to the It would be nice to have a similar shortcut on the server side such as It's nice to have the granular control over everything, but we lack that same control on the client side. This came up because I was trying to integrate the HeadlessUI 2.0 ComboBox without relying on the |
|
The new future exports sound great, seems like some corners have been straightened out (or removed) 👍🏻 Do I understand correctly though, that the only way to infer the FormShape is by passing a standard-schema So if I do not want to rely on schema for some reason, I have to pass the FormShape type parameter manually, correct? (Click here for Context)I'm still not happy with Zod's i18n support, even though it has improved with v4. Therefore, I need to pass my own i18n errormap to the parse function, which is not possible via Zod's standard-schema implementation. So I have to either wrap that, or do the parsing in onValidate and pass the inferred type myself. Or find another way to get happy with Zod i18n :) |
|
Hi, I saw PR #1041 , which adds support for passing schema Do you think the same improvement could also apply to the // current
onValidate: ({ payload }) => {
const result = schema.safeParse(payload);
const { value, error } = formatResult(result, { includeValue: true });
if (value) setState(value);
return error;
},
// proposed
onValidate: ({ payload }) => {
const result = schema.safeParse(payload);
if (result.success) {
setState(result.data);
return null;
}
return { issues: result.error.issues };
},This would:
Would you consider extending the same support to |
|
I find myself tripping over the missing DefaultMetadata export often when trying to write some abstractions for my forms. For example, I want to write a Field component that takes a FieldMeta with ErrorShape restricted to string, which seems impossible without re-defining DefaultMetadata myself. |
|
How does that work with asynchronous schema validation on the client side? I played around with standard schema yesterday, and noticed that some fields would not should validation errors when I submitted the form empty. When I switched from safeParseAsync to safeParse, it worked again. Does async validation require some kind of suspense around the form elements? |
|
I am trying to add new value by using intent from useForm, but my ide show me type error - intent.insert({
name: name,
index: index + 1,
defaultValue: "",
});
|
|
Conform v1 allow to set intent.update({
name: fields.name.name,
value: "",
});
intent.update({
name: age,
value: "",
});
intent.update({
name: fields.year.name,
value: "",
});
intent.update({
name: fields.field.name,
value: "",
});when default values are initially populated |
Is it possible to introduce wrapper around this code? Base on my codebase, I think not only my, it will wrapped in some function |
|
@edmundhung I have three questions about the new API:
This is my stab at it: <Field data-invalid={fields.email.ariaInvalid}>
<FieldLabel htmlFor={fields.email.id}>
{t("emailLabel")}
</FieldLabel>
<InputGroup>
<InputGroupInput
aria-describedby={fields.email.ariaDescribedBy}
aria-invalid={fields.email.ariaInvalid}
id={fields.email.id}
name={fields.email.name}
placeholder={t("emailPlaceholder")}
type="email"
/>
<InputGroupAddon>
<MailIcon />
</InputGroupAddon>
</InputGroup>
<FieldError
errors={fields.email.errors}
id={fields.email.errorId}
/>
</Field>
Here is an example. It is close to what I mean, but the second intent does not carry any data. My main question is how to handle multiple data-carrying intents in a single schema.import type { SubmissionResult } from "@conform-to/react";
import { getInputProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod/v4";
import { APIError } from "better-auth";
import { TriangleAlertIcon } from "lucide-react";
import { useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import { data, Form, href, redirect, useNavigation } from "react-router";
import { promiseHash } from "remix-utils/promise";
import * as z from "zod";
import { getIsOTPExpired } from "./+/check-otp-expiration.server";
import {
destroyEmailCookie,
getEmailFromCookie,
setEmailCookie,
} from "./+/email-otp-cookie.server";
import { useCountdown } from "./+/use-countdown";
import type { Route } from "./+types/verify";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSeparator,
FieldSet,
} from "~/components/ui/field";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "~/components/ui/input-otp";
import { Spinner } from "~/components/ui/spinner";
import { auth } from "~/lib/auth.server";
import { combineHeaders } from "~/lib/combine-headers.server";
import { getInstance } from "~/lib/i18next-middleware.server";
import { createToastHeaders, redirectWithToast } from "~/lib/toast.server";
export async function loader({ request, context }: Route.LoaderArgs) {
const i18next = getInstance(context);
const t = i18next.getFixedT(null, "auth", "verify");
const email = await getEmailFromCookie(request);
if (!email) {
return await redirectWithToast("/login", {
description: t("otpExpiredDescription"),
title: t("otpExpired"),
type: "error",
});
}
const isOTPExpired = await getIsOTPExpired(email);
if (isOTPExpired) {
return await redirectWithToast(
"/login",
{
description: t("otpExpiredDescription"),
title: t("otpExpired"),
type: "error",
},
{ headers: await destroyEmailCookie() },
);
}
return {};
}
export const SEND_VERIFICATION_OTP_INTENT = "sendVerificationOtp" as const;
export const SIGN_IN_EMAIL_OTP_INTENT = "signInEmailOtp" as const;
const OTP_LENGTH = 6;
z.config({ jitless: true });
const otpSchema = z.object({
intent: z.literal(SIGN_IN_EMAIL_OTP_INTENT),
otp: z.string().length(OTP_LENGTH).default(""),
});
const schema = z.discriminatedUnion("intent", [
z.object({ intent: z.literal(SEND_VERIFICATION_OTP_INTENT) }),
otpSchema,
]);
export async function action({ request, context }: Route.ActionArgs) {
const i18next = getInstance(context);
const t = i18next.getFixedT(null, "auth", "verify");
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return data(submission.reply(), { status: 400 });
}
const email = await getEmailFromCookie(request);
if (!email) {
return await redirectWithToast("/login", {
description: t("otpExpiredDescription"),
title: t("otpExpired"),
type: "error",
});
}
switch (submission.value.intent) {
case SIGN_IN_EMAIL_OTP_INTENT: {
try {
const { headers, destroyEmailCookieHeaders } = await promiseHash({
destroyEmailCookieHeaders: destroyEmailCookie(),
headers: auth.api
.signInEmailOTP({
body: { email, otp: submission.value.otp },
returnHeaders: true,
})
.then((result) => result.headers),
});
return redirect(href("/onboarding"), {
headers: combineHeaders(headers, destroyEmailCookieHeaders),
});
} catch (error) {
if (error instanceof APIError) {
const isTooManyAttempts = error.body?.code === "TOO_MANY_ATTEMPTS";
const forbiddenStatus = 403;
const badRequestStatus = 400;
return data(
submission.reply({
fieldErrors: { otp: [error.body?.message ?? ""] },
}),
{
status: isTooManyAttempts ? forbiddenStatus : badRequestStatus,
...(isTooManyAttempts && { headers: await destroyEmailCookie() }),
},
);
}
throw error;
}
}
case SEND_VERIFICATION_OTP_INTENT: {
const [toastHeaders] = await Promise.all([
createToastHeaders({
description: t("newOtpSentDescription"),
title: t("newOtpSent"),
type: "success",
}),
auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }),
]);
return data(
{ lastReset: new Date().toISOString() },
{
headers: combineHeaders(toastHeaders, await setEmailCookie(email)),
},
);
}
}
}
export default function VerifyRoute({ actionData }: Route.ComponentProps) {
const { t } = useTranslation("auth", { keyPrefix: "verify" });
const { secondsLeft, reset } = useCountdown(60);
const lastReset = (actionData as { lastReset: string } | undefined)
?.lastReset;
useEffect(() => {
if (lastReset) {
reset();
}
}, [lastReset, reset]);
const waitingToResend = secondsLeft !== 0;
const navigation = useNavigation();
const isResending =
navigation.formData?.get("intent") === SEND_VERIFICATION_OTP_INTENT;
const isVerifying =
navigation.formData?.get("intent") === SIGN_IN_EMAIL_OTP_INTENT;
const isSubmitting = isResending || isVerifying;
const [form, fields] = useForm({
constraint: getZodConstraint(otpSchema),
lastResult:
navigation.state === "idle"
? (actionData as SubmissionResult<string[]> | undefined)
: null,
onValidate({ formData }) {
return parseWithZod(formData, { schema: otpSchema });
},
shouldRevalidate: navigation.state === "idle" ? "onBlur" : undefined,
shouldValidate: "onBlur",
});
return (
<>
<title>{t("meta.title")}</title>
<div className="grid gap-4">
<Form id={form.id} method="POST" noValidate onSubmit={form.onSubmit}>
<FieldSet disabled={isSubmitting}>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground text-sm text-balance">
{t("subtitle")}
</p>
</div>
<Field data-invalid={!fields.otp.valid}>
<FieldLabel className="sr-only" htmlFor={fields.otp.id}>
{t("fieldLabel")}
</FieldLabel>
<div className="flex w-full justify-center">
<InputOTP
{...getInputProps(fields.otp, {
ariaDescribedBy: fields.otp.descriptionId,
type: "text",
})}
maxLength={OTP_LENGTH}
>
<InputOTPGroup className="gap-2 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup className="gap-2 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border">
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup className="gap-2 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border">
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<FieldDescription
className="text-center"
id={fields.otp.descriptionId}
>
{t("description")}
</FieldDescription>
<FieldError
errors={fields.otp.errors}
id={fields.otp.errorId}
/>
</Field>
<Button
name="intent"
type="submit"
value={SIGN_IN_EMAIL_OTP_INTENT}
>
{t("verifyButton")}
</Button>
</FieldGroup>
</FieldSet>
</Form>
<FieldSeparator />
<Form method="POST" onSubmit={() => reset()}>
<FieldSet disabled={waitingToResend || isSubmitting}>
<Alert>
<TriangleAlertIcon />
<AlertDescription>{t("alertDescription")}</AlertDescription>
</Alert>
<FieldDescription className="text-muted-foreground text-xs">
<Trans
components={{ 1: <b /> }}
count={secondsLeft}
i18nKey="verify.countdownMessage"
ns="auth"
/>
</FieldDescription>
<Button
className="w-full"
name="intent"
type="submit"
value={SEND_VERIFICATION_OTP_INTENT}
>
{isResending ? (
<>
<Spinner />
{t("resendButtonSubmitting")}
</>
) : (
t("resendButton")
)}
</Button>
</FieldSet>
</Form>
</div>
</>
);
} |
|
@edmundhung Reading through all the comments here, I'm confused about this 👇
Don't you usually also need things like |
Second this! We have to go from this: export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return data(submission.reply(), { status: 400 });
}
const { email } = submission.value;
await auth.api.sendVerificationOTP({
body: { email, type: "sign-in" },
});
return redirect(href("/verify"), { headers: await setEmailCookie(email) });
}To this: export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const submission = parseSubmission(formData);
const result = schema.safeParse(submission.payload);
if (!result.success) {
return data(
{
result: report(submission, { error: { issues: result.error.issues } }),
},
{ status: 400 },
);
}
const { email } = result.data;
await auth.api.sendVerificationOTP({
body: { email, type: "sign-in" },
});
return redirect(href("/verify"), { headers: await setEmailCookie(email) });
}I think the new API is more ugly than the old one due to the boilerplate. Buuut one could create a custom wrapper. Here is my stab at it: function validateFormData<T extends z.ZodTypeAny>(
formData: FormData,
schema: T,
) {
const submission = parseSubmission(formData);
const result = schema.safeParse(submission.payload);
if (!result.success) {
return {
response: data(
{
result: report(submission, {
error: { issues: result.error.issues },
}),
},
{ status: 400 },
),
success: false as const,
};
}
return { data: result.data, success: true as const };
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const result = validateFormData(formData, schema);
if (!result.success) {
return result.response;
}
const { email } = result.data;
await auth.api.sendVerificationOTP({
body: { email, type: "sign-in" },
});
return redirect(href("/verify"), { headers: await setEmailCookie(email) });
} |
|
With ✅ a missing const { fields, form } = useForm({
schema,
});✅ a null const { fields, form } = useForm({
lastResult: null,
schema,
});❌ an undefined const { fields, form } = useForm({
lastResult: undefined,
schema,
}); |
|
In terms of API design, what is this supposed to do? report(submission, {
reset: true,
intendedValue: {
foo: "bar"
}
});The current behavior, as far as I can tell, is to silently ignore Wouldn't it feel more reasonable to perform the state reset, but reset the field values to |
|
@edmundhung I think I found a bug: Optional
|
|
@edmundhung Are there needs for better prop helpers for the form? I think in order to make it accessible, we still need to wire up a lot of attributes (in other words, is <Form
encType="multipart/form-data"
method="POST"
{...form.props}
aria-describedby={
form.errors && form.errors.length > 0
? `${form.descriptionId} ${form.errorId}`
: form.descriptionId
}
aria-invalid={form.errors && form.errors.length > 0 ? true : undefined}
>
<FieldSet disabled={isSubmitting}>
<FieldGroup>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold">{t("heading")}</h1>
<p
className="text-muted-foreground text-sm text-pretty"
id={form.descriptionId}
>
{t("subtitle")}
</p>
</div>
{form.errors && form.errors.length > 0 && (
<Alert id={form.errorId} variant="destructive">
<AlertTitle>
{t("errors.createOrganizationFailedTitle")}
</AlertTitle>
<AlertDescription>
{t("errors.createOrganizationFailedDescription")}
</AlertDescription>
</Alert>
)} |
|
Hey, I am a bit confused about some of the changes that's happening on the future export. So is the And if Edit: Ok I think things are bit clear for me now, though it took a bit of trial and error to actually figue out it out. So the idea now is that we pass the Zod schema as the first argument to the If not, we can also use the At least that's how I think the pattern looks like in the client. Need to check how the server side looks later. |
|
Can virtual scrolling be achieved with the new |
|
The new future exports have been going pretty well so far. Thanks for the updates! One missing piece that's been tripping me up is not having a It happens even when provided the same formId passed to |

Uh oh!
There was an error while loading. Please reload this page.
We are super excited to announce the future
useFormhook today! This brings first-class support for React 19 Server Actions, a refined validation setup, and more.The motivation
When we first built Conform v1, we had a simple goal: make form state as accessible as possible to developers. We wanted you to just write
fields.email.valueand have everything work magically.So we synced all form values into React state and built complex subscription patterns to avoid re-renders. It worked beautifully—until we realized we had created something that was incredibly hard to debug and reason about.
Over the last year, we have been thinking about how to simplify this, and then came the lightbulb moment: Conform already uses the DOM as the source of truth. Why are we duplicating all this state in React? What if we just... didn't?
The new useForm hook is the result of that realization. No form values in React state. The rest of the state like errors or touched fields doesn't change that often, so we can model it with a simple
useState. This makes the architecture much simpler and easier to maintain.What's different
This release has addressed a lot of the outstanding issues with new features and improvements. Here are some of the highlights:
Metadata
The most obvious change is that the new hook returns a structured object instead of an array tuple, with some metadata changes:
form.propsdirectly instead of usinggetFormProps(form)form.validate()andform.reset()are now available on theintentobject insteadform.dirty) is removed. Use useFormData() with the isDirty helper if you need this.getField(name),getFieldset(name)andgetFieldList(name).fields.email.value) are removed. Use useFormData() to read form values when needed.fields.email.initialValue) are removed. Usefields.email.defaultValue/fields.email.defaultOptions/fields.email.defaultCheckedinstead.We have also adjusted the return type of useField(), which returns a single field's metadata instead of an array tuple with both form and field metadata:
The arguments of useFormMetadata() have also changed to accept an object with optional
formIdinstead of aformIdstring directly.Validation
Another big change is the validation system. The
onValidatecallback now receives the parsedpayloadalongside theFormDataand requires you to return a structured error:Of course, we have helpers to integrate with popular schema libraries like Zod:
But we have something even better: standard schema support. Simply pass your schema to
useFormand it will handle parsing and validation for you:On the server side, you will find a new parseSubmission utility that parses FormData into a structured submission object with a clean payload for easy validation. Use the report helper to send results back to the client:
We have also improved client async validation support. You can now define async validation directly in your schema and pass it to useForm:
However, validation might trigger regardless of which field the user is typing in, so we recommend using a memoize utility to cache async validation results:
You can also validate manually in the
onValidatecallback if you need more control. See the examples section below for more details.Progressive enhancement
The new design takes a more targeted approach to progressive enhancement. You still get core support like preserving user input on validation errors, but some advanced features require additional setup:
parseSubmissiondoesn't handle intents automaticallygetButtonProps()method is no longer availableWe still believe the best way to build resilient web apps is to use the web platform extensively. However, building a form with a complete no-JS experience introduces additional complexity that not every application needs. The new design still supports full progressive enhancement, but it requires additional setup that we will document separately.
Improved intent handling
Previously, calling multiple form actions required wrapping each in
flushSyncto avoid form state sync issues. The newintentsystem eliminates this need:You can also access the intent object anywhere in your component tree using the useIntent() hook:
Additional improvements
Custom error handling: By default, Conform focuses the first invalid field when validation fails. You can now customize this behavior with the new
onErroroption:React 19 compatibility: Previously, Conform required a special workaround to disable React 19's automatic form reset behavior. We have adjusted the state model so it now works seamlessly with Server Actions.
Examples
We have updated several examples to showcase the new
useFormhook, including a new React SPA example:Try it out and share your feedback! The API is stabilizing, but we are still open to suggestions before the official v2 release.
All reactions