Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
.Form {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 16rem;
}

.Field {
display: flex;
flex-direction: column;
align-items: start;
gap: 0.25rem;
}

.Label {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
color: var(--color-gray-900);
}

.Input {
box-sizing: border-box;
padding-left: 0.875rem;
margin: 0;
border: 1px solid var(--color-gray-200);
width: 100%;
height: 2.5rem;
border-radius: 0.375rem;
font-family: inherit;
font-size: 1rem;
background-color: transparent;
color: var(--color-gray-900);

&:focus {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}

.Error {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-red-800);
}

.Button {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
height: 2.5rem;
padding: 0 0.875rem;
margin: 0;
outline: 0;
border: 1px solid var(--color-gray-200);
border-radius: 0.375rem;
background-color: var(--color-gray-50);
font-family: inherit;
font-size: 1rem;
font-weight: 500;
line-height: 1.5rem;
color: var(--color-gray-900);
user-select: none;

@media (hover: hover) {
&:hover {
background-color: var(--color-gray-100);
}
}

&:active {
background-color: var(--color-gray-100);
}

&:disabled {
cursor: not-allowed;
color: var(--color-gray-400);
background-color: var(--color-gray-100);
}

&:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as React from 'react';
import { Field } from '@base-ui-components/react/field';
import { Form } from '@base-ui-components/react/form';
import styles from './index.module.css';

interface FormState {
success: boolean;
serverErrors: { [fieldName: string]: string | undefined };
}

export default function ActionStateForm() {
const [state, formAction, loading] = React.useActionState<FormState, FormData>(submitForm, {
success: false,
serverErrors: {},
});

return (
<Form className={styles.Form} action={formAction}>
<Field.Root name="username" className={styles.Field}>
<Field.Label className={styles.Label}>Username</Field.Label>
<Field.Control
type="username"
required
defaultValue="admin"
placeholder="e.g. alice132"
className={styles.Input}
/>
<Field.Error className={styles.Error} />

<Field.Error match={!!state.serverErrors.username}>
{state.serverErrors.username}
</Field.Error>
Comment on lines +28 to +32
Copy link
Member Author

Choose a reason for hiding this comment

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

When returning errors with useActionState, passing them to the errors prop forces the validity state to be invalid: https://github.com/mui/base-ui/blob/master/packages/react/src/field/utils/getCombinedFieldValidityData.ts#L15

This blocks re-submitting the form for revalidation on the server (in the server fn) so the error gets stuck

</Field.Root>
<button disabled={loading} type="submit" className={styles.Button}>
Submit
</button>
</Form>
);
}

async function submitForm(_previousState: FormState, formData: FormData) {
// Mimic a server response
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});

try {
const username = formData.get('username') as string | null;

if (username === 'admin') {
return { success: false, serverErrors: { username: "'admin' is reserved for system use" } };
}

// 50% chance the username is taken
const success = Math.random() > 0.5;

if (!success) {
return {
success: false,
serverErrors: { username: `${username} is unavailable` },
};
}
} catch {
return { success: false, serverErrors: { username: 'A server error has occurred' } };
}

return { success: true, serverErrors: {} };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use client';
export { default as CssModules } from './css-modules';
export { default as Tailwind } from './tailwind';
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as React from 'react';
import { Field } from '@base-ui-components/react/field';
import { Form } from '@base-ui-components/react/form';

interface FormState {
success: boolean;
serverErrors: { [fieldName: string]: string | undefined };
}

export default function ActionStateForm() {
const [state, formAction, loading] = React.useActionState<FormState, FormData>(submitForm, {
success: false,
serverErrors: {},
});

return (
<Form className="flex w-full max-w-64 flex-col gap-4" action={formAction}>
Copy link
Contributor

Choose a reason for hiding this comment

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

onSubmit (or the internal effect from errors prop + submittedRef.current) handles focusing the first control with an error, which seems to be missing here. Does something else need to be done?

Copy link
Member Author

@mj12albert mj12albert Oct 15, 2025

Choose a reason for hiding this comment

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

Oh it's because I'm not passing the errors (assumed to be returned by a server/externally) to the errors prop #2983 (comment)

Maybe here: https://github.com/mui/base-ui/blob/master/packages/react/src/form/Form.tsx#L64
...could we assume that external errors via the errors prop are expected to be cleared externally (server-side) and shouldn't block submission? 🤔

Somewhat related - I'm not sure where onClearErrors should fit into all of this #2758

Copy link
Contributor

@atomiks atomiks Oct 15, 2025

Choose a reason for hiding this comment

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

could we assume that external errors via the errors prop are expected to be cleared externally (server-side) and shouldn't block submission? 🤔

I think this relates to Field.Root's validationMode prop - that makes sense for onSubmit but not onBlur and onChange? Maybe Form should be able to override all of their props with its own validationMode prop that you can set to onSubmit, in which case that behavior would make sense, because then if there are any errors it shouldn't block submission as the validation happens on submit

<Field.Root name="username" className="flex flex-col items-start gap-1">
<Field.Label className="text-sm font-medium text-gray-900">Username</Field.Label>
<Field.Control
type="username"
required
defaultValue="admin"
placeholder="e.g. alice132"
className="h-10 w-full rounded-md border border-gray-200 pl-3.5 text-base text-gray-900 focus:outline focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800"
/>
<Field.Error className="text-sm text-red-800" />

<Field.Error className="text-sm text-red-800" match={!!state.serverErrors.username}>
{state.serverErrors.username}
</Field.Error>
</Field.Root>
<button
disabled={loading}
type="submit"
className="flex h-10 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3.5 text-base font-medium text-gray-900 select-none hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 active:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
>
Submit
</button>
</Form>
);
}

async function submitForm(_previousState: FormState, formData: FormData) {
// Mimic a server response
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});

try {
const username = formData.get('username') as string | null;

if (username === 'admin') {
return { success: false, serverErrors: { username: "'admin' is reserved for system use" } };
}

// 50% chance the username is taken
const success = Math.random() > 0.5;

if (!success) {
return {
success: false,
serverErrors: { username: `${username} is unavailable` },
};
}
} catch {
return { success: false, serverErrors: { username: 'A server error has occurred' } };
}

return { success: true, serverErrors: {} };
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ import { Form } from '@base-ui-components/react/form';

## Examples

### Submit with a Server Function

Forms can be submitted with a [Server Function](https://react.dev/reference/react-dom/components/form#handle-form-submission-with-a-server-function) instead of `onSubmit`.

<Demo path="./demos/form-action" />

### Using with Zod

When parsing the schema using `schema.safeParse()`, the `z.flattenError(result.error).fieldErrors` data can be used to map the errors to each field's `name`.
Expand Down
Loading