Skip to content

Commit f08bc63

Browse files
committed
prototyping form actions
1 parent 99d8920 commit f08bc63

7 files changed

Lines changed: 295 additions & 13 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// Mark as a client only package. This will cause a build time error if you try
14+
// to import it from a React Server Component in a framework like Next.js.
15+
import 'client-only';
16+
17+
export {Alert, AlertContext} from '../src/Alert';
18+
export type {AlertProps, AlertRenderProps} from '../src/Alert';

packages/react-aria-components/exports/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// to import it from a React Server Component in a framework like Next.js.
1515
import 'client-only';
1616

17+
export {Alert, AlertContext} from '../src/Alert';
1718
export {Autocomplete, AutocompleteContext, AutocompleteStateContext, SelectableCollectionContext, FieldInputContext} from '../src/Autocomplete';
1819
export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from '../src/Breadcrumbs';
1920
export {Button, ButtonContext} from '../src/Button';
@@ -101,6 +102,7 @@ export type {CollectionProps} from 'react-aria/Collection';
101102
export type {Placement} from 'react-aria/useOverlayPosition';
102103
export type {VisuallyHiddenProps} from 'react-aria/VisuallyHidden';
103104

105+
export type {AlertProps, AlertRenderProps} from '../src/Alert';
104106
export type {AutocompleteProps, SelectableCollectionContextValue} from '../src/Autocomplete';
105107
export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from '../src/Breadcrumbs';
106108
export type {ButtonProps, ButtonRenderProps} from '../src/Button';
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {ContextValue, RenderProps, useContextProps, useRenderProps} from './utils';
14+
import {DOMProps} from '@react-types/shared';
15+
import {filterDOMProps} from 'react-aria/filterDOMProps';
16+
import React, {createContext, ForwardedRef, forwardRef, useEffect, useRef} from 'react';
17+
import {useFocusRing} from 'react-aria/useFocusRing';
18+
import {useObjectRef} from 'react-aria/useObjectRef';
19+
20+
export interface AlertProps extends RenderProps<AlertRenderProps>, DOMProps {
21+
/**
22+
* Whether to automatically focus the alert when it first renders.
23+
*/
24+
autoFocus?: boolean
25+
}
26+
27+
export interface AlertRenderProps {
28+
/**
29+
* Whether the button is focused, either via a mouse or keyboard.
30+
* @selector [data-focused]
31+
*/
32+
isFocused: boolean,
33+
/**
34+
* Whether the button is keyboard focused.
35+
* @selector [data-focus-visible]
36+
*/
37+
isFocusVisible: boolean
38+
}
39+
40+
export const AlertContext = createContext<ContextValue<AlertProps, HTMLDivElement>>(null);
41+
42+
export const Alert = forwardRef(function Alert(props: AlertProps, ref: ForwardedRef<HTMLDivElement>) {
43+
[props, ref] = useContextProps(props, ref, AlertContext);
44+
let domProps = filterDOMProps(props, {global: true})!;
45+
let {isFocused, isFocusVisible, focusProps} = useFocusRing({autoFocus: props.autoFocus});
46+
let renderProps = useRenderProps({
47+
...props,
48+
defaultClassName: 'react-aria-Alert',
49+
values: {
50+
isFocused,
51+
isFocusVisible
52+
}
53+
});
54+
55+
let autoFocusRef = useRef(props.autoFocus);
56+
let domRef = useObjectRef(ref);
57+
useEffect(() => {
58+
if (autoFocusRef.current && domRef.current) {
59+
domRef.current.focus();
60+
}
61+
autoFocusRef.current = false;
62+
}, [domRef]);
63+
64+
return (
65+
<div
66+
{...domProps}
67+
{...focusProps}
68+
{...renderProps}
69+
ref={domRef}
70+
role="alert"
71+
tabIndex={props.autoFocus ? -1 : undefined} />
72+
);
73+
});

packages/react-aria-components/src/Button.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ import {
2222
} from './utils';
2323
import {createHideableComponent} from 'react-aria/private/collections/Hidden';
2424
import {filterDOMProps} from 'react-aria/filterDOMProps';
25+
import {FormPendingContext} from './Form';
2526
import {GlobalDOMAttributes} from '@react-types/shared';
2627
import {HoverEvents} from '@react-types/shared';
2728
import {mergeProps} from 'react-aria/mergeProps';
2829
import {ProgressBarContext} from './ProgressBar';
29-
import React, {createContext, ForwardedRef} from 'react';
30+
import React, {createContext, ForwardedRef, useContext} from 'react';
3031
import {useFocusRing} from 'react-aria/useFocusRing';
3132
import {useHover} from 'react-aria/useHover';
3233

@@ -88,7 +89,14 @@ export const ButtonContext = createContext<ContextValue<ButtonContextValue, HTML
8889
export const Button = /*#__PURE__*/ createHideableComponent(function Button(props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
8990
[props, ref] = useContextProps(props, ref, ButtonContext);
9091
let ctx = props as ButtonContextValue;
91-
let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton(props, ref);
92+
93+
// Ideally we would use React's `useFormStatus` for this but it is buggy.
94+
// https://github.com/facebook/react/issues/30368
95+
let isFormPending = useContext(FormPendingContext);
96+
let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton({
97+
...props,
98+
isPending: props.isPending || (props.type === 'submit' && isFormPending)
99+
}, ref);
92100
let {focusProps, isFocused, isFocusVisible} = useFocusRing(props);
93101
let {hoverProps, isHovered} = useHover({
94102
...props,

packages/react-aria-components/src/Form.tsx

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ContextValue, dom, DOMProps, DOMRenderProps, useContextProps} from './utils';
13+
import {AlertContext} from './Alert';
14+
import {ContextValue, dom, Provider, RenderProps, useContextProps, useRenderProps} from './utils';
1415
import {FormValidationContext} from 'react-stately/private/form/useFormValidationState';
1516
import {GlobalDOMAttributes, FormProps as SharedFormProps} from '@react-types/shared';
16-
import React, {createContext, ForwardedRef, forwardRef} from 'react';
17+
import React, {createContext, ForwardedRef, forwardRef, useMemo} from 'react';
18+
import {useAction} from 'react-stately/private/utils/useAction';
1719

18-
export interface FormProps extends SharedFormProps, DOMProps, DOMRenderProps<'form', undefined>, GlobalDOMAttributes<HTMLFormElement> {
20+
export interface FormProps extends SharedFormProps, RenderProps<FormRenderProps, 'form'>, GlobalDOMAttributes<HTMLFormElement> {
1921
/**
2022
* The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element.
2123
* @default 'react-aria-Form'
@@ -27,25 +29,109 @@ export interface FormProps extends SharedFormProps, DOMProps, DOMRenderProps<'fo
2729
* or invalid via ARIA.
2830
* @default 'native'
2931
*/
30-
validationBehavior?: 'aria' | 'native'
32+
validationBehavior?: 'aria' | 'native',
33+
/**
34+
* Async action that is called when the value changes.
35+
* This differs from the React's `action` prop in a few ways:
36+
*
37+
* * Errors thrown during the action are caught and passed to the `actionError` render prop.
38+
* * The pending state is automatically passed to the form's submit button.
39+
* * The form is not automatically reset after the action completes.
40+
*/
41+
submitAction?: (data: FormData) => void | Promise<void>
42+
}
43+
44+
export interface FormRenderProps {
45+
/**
46+
* Whether the form's submit action is pending.
47+
* @selector [data-pending]
48+
*/
49+
isPending: boolean,
50+
/**
51+
* The last error that occurred within the form's submit action.
52+
* @selector [data-action-error]
53+
*/
54+
actionError: unknown | null
3155
}
3256

3357
export const FormContext = createContext<ContextValue<FormProps, HTMLFormElement>>(null);
58+
export const FormPendingContext = createContext<boolean>(false);
3459

3560
/**
3661
* A form is a group of inputs that allows users to submit data to a server,
3762
* with support for providing field validation errors.
3863
*/
3964
export const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef<HTMLFormElement>) {
4065
[props, ref] = useContextProps(props, ref, FormContext);
41-
let {validationErrors, validationBehavior = 'native', children, className, ...domProps} = props;
66+
let {validationErrors, validationBehavior = 'native', children, className, style, submitAction, action, onSubmit, ...domProps} = props;
67+
68+
let [onAction, isPending, actionError] = useAction(submitAction);
69+
let [formError, fieldErrors] = useMemo(() => {
70+
// Support errors from libraries conforming to the Standard Schema spec: https://standardschema.dev/schema
71+
if (actionError && typeof actionError === 'object' && Array.isArray(actionError['issues'])) {
72+
let formErrors: string[] = [];
73+
let fieldErrors: Record<string, string[]> = {};
74+
for (let issue of actionError['issues']) {
75+
if (
76+
issue &&
77+
typeof issue === 'object' &&
78+
typeof issue.message === 'string'
79+
) {
80+
if (Array.isArray(issue.path) && issue.path.length > 0 && typeof issue.path[0] === 'string') {
81+
fieldErrors[issue.path[0]] ||= [];
82+
fieldErrors[issue.path[0]].push(issue.message);
83+
} else {
84+
formErrors.push(issue.message);
85+
}
86+
}
87+
}
88+
89+
return [formErrors.length > 0 ? formErrors : null, fieldErrors];
90+
91+
// Alternative error shape based on Zod's flattenError result: https://zod.dev/error-formatting#zflattenerror
92+
} else if (actionError && typeof actionError === 'object' && (actionError['formErrors'] || actionError['fieldErrors'])) {
93+
return [actionError['formErrors'], actionError['fieldErrors']];
94+
}
95+
96+
return [actionError, null];
97+
}, [actionError]);
98+
99+
let renderProps = useRenderProps({
100+
children,
101+
className,
102+
style,
103+
defaultClassName: 'react-aria-Form',
104+
values: {
105+
isPending,
106+
actionError: formError
107+
}
108+
});
109+
42110
return (
43-
<dom.form noValidate={validationBehavior !== 'native'} {...domProps} ref={ref} className={className || 'react-aria-Form'}>
44-
<FormContext.Provider value={{...props, validationBehavior}}>
45-
<FormValidationContext.Provider value={validationErrors ?? {}}>
46-
{children}
47-
</FormValidationContext.Provider>
48-
</FormContext.Provider>
111+
<dom.form
112+
noValidate={validationBehavior !== 'native'}
113+
{...domProps}
114+
{...renderProps}
115+
ref={ref}
116+
data-pending={isPending || undefined}
117+
data-action-error={!!formError || undefined}
118+
action={action}
119+
onSubmit={e => {
120+
onSubmit?.(e);
121+
if (onAction) {
122+
e.preventDefault();
123+
onAction(new FormData(e.currentTarget));
124+
}
125+
}}>
126+
<Provider
127+
values={[
128+
[FormContext, {...props, validationBehavior}],
129+
[FormValidationContext, validationErrors ?? fieldErrors ?? {}],
130+
[AlertContext, {autoFocus: !!formError}],
131+
[FormPendingContext, isPending]
132+
]}>
133+
{renderProps.children}
134+
</Provider>
49135
</dom.form>
50136
);
51137
});

packages/react-aria-components/stories/Form.stories.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
*/
1212

1313
import {action} from 'storybook/actions';
14+
import {Alert} from '../src/Alert';
1415
import {Button} from '../src/Button';
16+
import {FieldError} from '../src/FieldError';
1517
import {Form} from '../src/Form';
1618
import {Input} from '../src/Input';
1719
import {Label} from '../src/Label';
@@ -77,3 +79,51 @@ export const FormAutoFillExample: FormStory = () => {
7779
);
7880
};
7981

82+
export const FormErrorExample: FormStory = () => {
83+
return (
84+
<Form
85+
style={{display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'start'}}
86+
submitAction={async (formData) => {
87+
await new Promise(resolve => setTimeout(resolve, 1000));
88+
89+
let name = formData.get('name');
90+
if (!name) {
91+
throw {
92+
issues: [{
93+
message: 'Enter your name',
94+
path: ['name']
95+
}]
96+
};
97+
}
98+
99+
if (name === 'test') {
100+
throw 'Could not create account. Please try again later.';
101+
}
102+
}}>
103+
{({actionError}) => (<>
104+
<p>Submit an empty value for a field-level error.<br />Enter "test" to see a form-level error.</p>
105+
{actionError &&
106+
<Alert
107+
style={({isFocusVisible}) => ({
108+
border: '2px solid red',
109+
padding: 16,
110+
outline: isFocusVisible ? '2px solid blue' : undefined,
111+
outlineOffset: 2
112+
})}>
113+
{String(actionError)}
114+
</Alert>
115+
}
116+
<TextField
117+
name="name"
118+
style={{display: 'flex', flexDirection: 'column'}}>
119+
<Label>Name</Label>
120+
<Input />
121+
<FieldError style={{color: 'red'}} />
122+
</TextField>
123+
<Button type="submit">
124+
{({isPending}) => isPending ? 'Submitting...' : 'Submit'}
125+
</Button>
126+
</>)}
127+
</Form>
128+
);
129+
};

rfcs/2026-async-react.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,51 @@ function App() {
207207
}
208208
```
209209

210+
#### Form errors
211+
212+
When an error is thrown in a form's `submitAction`, it will be available via the `actionError` render prop. This can be displayed to the user by rendering an `<Alert>`, which will be focused and announced by screen readers. For field-level errors (e.g. server validation), a special error object compatible with [Standard Schema](https://standardschema.dev/schema) could be supported, allowing these errors to be automatically propagated to the correct fields (as we support via the `validationErrors` prop today).
213+
214+
**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`:
215+
216+
* Errors thrown during the action are caught and passed to the `actionError` render prop.
217+
* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment.
218+
* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`.
219+
220+
```tsx
221+
function App() {
222+
return (
223+
<Form
224+
submitAction={async (formData) => {
225+
let email = formData.get('email');
226+
if (!await isAccountAvailable(email)) {
227+
throw {
228+
issues: [{
229+
message: 'An account with that email already exists',
230+
path: ['email']
231+
}]
232+
}
233+
}
234+
235+
try {
236+
await createAccount(email);
237+
} catch {
238+
throw 'Could not create account';
239+
}
240+
}}>
241+
{({actionError}) => (
242+
<>
243+
{actionError &&
244+
<Alert>{String(actionError)}</Alert>
245+
}
246+
<TextField name="email" />
247+
<Button type="submit">Submit</Button>
248+
</>
249+
)}
250+
</Form>
251+
);
252+
}
253+
```
254+
210255
## Documentation
211256

212257
We'll add new examples to our documentation showing how to use action props, and add pending states to components in our starter kits.

0 commit comments

Comments
 (0)