- 
          
- 
                Notifications
    You must be signed in to change notification settings 
- Fork 258
          [docs][form] Add an example with React.useActionState
          #2983
        
          New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| </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}> | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 
 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Somewhat related - I'm not sure where  There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 
 I think this relates to  | ||
| <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: {} }; | ||
| } | ||
There was a problem hiding this comment.
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#L15This blocks re-submitting the form for revalidation on the server (in the server fn) so the error gets stuck