mui / mui/base-ui

Feature Request: First class support for async react primitives

Open
#5,133 1 comment 2 reactions 0 assignees View on GitHub
type: new feature waiting for 👍
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

# Feature request

## Summary

Base UI should expose async action props for user-driven changes, alongside the existing synchronous change callbacks.

This proposal is inspired by the Async React demo repo and the Async React Working Group direction: async user interactions should be modeled as actions with pending, error, and transition semantics, rather than as synchronous callbacks plus user-managed loading state.

Today, many component APIs treat changes as synchronous notifications:

```tsx

```

That remains useful for synchronous observation and local state updates, but it does not give the component a first-class way to know that a change is pending. Users often compensate by owning loading state manually:

```tsx
const [isSaving, setIsSaving] = React.useState(false);

async function handleCheckedChange(nextChecked: boolean) {
setIsSaving(true);
await savePreference(nextChecked);
setChecked(nextChecked);
setIsSaving(false);
}

```

That pattern should be considered the older synchronous React behavior. It is still valid, but it pushes pending state, duplicate-interaction guards, and async coordination onto every user even though React now has built-in async interaction primitives.

The proposed API is to add action variants for change props:

```tsx
{
await updateNotificationPreference(nextChecked);
}}
/>
```

```tsx
{
await updateSubscriptionPlan(nextPlan);
}}
>
{/* ... */}

```

```tsx
{
if (!nextOpen) {
await saveDraftBeforeClose();
}
}}
>
{/* ... */}

```

The naming convention would mirror existing change callbacks:

```txt
onOpenChange -> onOpenChangeAction
onValueChange -> onValueChangeAction
onCheckedChange -> onCheckedChangeAction
onPressedChange -> onPressedChangeAction
```

For buttons, this could be a direct `action` prop while the pending UI stays owned by the user through React's `useActionState`:

```tsx
function SaveButton() {
const [state, saveAction, pending] = React.useActionState(
async () => {
await save();
return { status: 'saved' };
},
{ status: 'idle' },
);

return {pending ? 'Saving...' : 'Save'};
}
```

For custom loading UI, users can branch with normal React rendering:

```tsx
function SaveButton() {
const [state, saveAction, pending] = React.useActionState(
async () => {
await save();
return { status: 'saved' };
},
{ status: 'idle' },
);

return (

{pending ? (
<>

Saving

) : (
<>

Save

)}

);
}
```

Base UI would own the interaction behavior:

- call the action from the relevant interaction
- run async actions through React transition/action semantics
- prevent duplicate activation while the action it invoked is pending
- preserve focus and component accessibility semantics
- surface rejected actions to React or framework-level error handling

Base UI should not own the visual loading treatment for buttons. Because Base UI is headless, users should decide whether a pending button shows a spinner, text swap, icon swap, progress indicator, or no visible loading treatment. The button also does not need a `pending` prop or pending compound part in the initial proposal. React already gives users the pending value through `useActionState`; Base UI's job is to make the activation path semantic, deduped, accessible, and compatible with React action/error behavior.

The benefit of the `action` prop is therefore not that Base UI renders the loading state for the user. The benefit is that Base UI can distinguish async activation from a normal `onClick`, invoke it through the correct interaction semantics, prevent duplicate activations while the action is in flight, preserve button accessibility behavior, and provide a consistent action surface across primitives.

For controls that require instant feedback, Base UI should own optimistic display state internally. Selects, checkboxes, switches, toggles, and similar value-changing primitives feel broken if the visible value waits for an async mutation to finish. When these components receive an action prop, Base UI should use optimistic state so the control responds immediately while the action is pending:

```tsx
{
await updateNotificationPreference(nextChecked);
}}
>

```

The switch should visually move to `nextChecked` immediately. The same principle applies to checkboxes becoming checked or unchecked, selects showing the newly chosen value, and other controls where immediate visual acknowledgement is part of the expected interaction.

The action flow for these components would be:

- the user requests the next value
- Base UI applies the next value to optimistic display state immediately
- Base UI starts the action in a transition/action context
- Base UI guards against duplicate or conflicting interactions while pending where appropriate
- when the action resolves, the component reconciles against the committed controlled or uncontrolled value
- when the action rejects, the error surfaces through React or framework-level error handling and the optimistic display reconciles back to the last committed value

For controlled components, the optimistic state is only the temporary display value while the async action is pending. The external controlled value remains the source of truth once it updates. For uncontrolled components, Base UI can update its internal optimistic value immediately and reconcile if the action fails.

CSS can adapt to pending or optimistic states through data attributes without requiring Base UI to prescribe the visual treatment:

```css
.SwitchRoot[data-pending] {
cursor: progress;
}

.SwitchRoot[data-optimistic] .SwitchThumb {
transition-duration: 120ms;
}

.SelectTrigger[data-pending] {
opacity: 0.8;
}
```

For button loading styles, users can attach their own state from `useActionState`:

```tsx
function SaveButton() {
const [state, saveAction, pending] = React.useActionState(save, null);

return (

{pending ? 'Saving...' : 'Save'}

);
}
```

```css
.Button[data-pending='true'] {
cursor: progress;
opacity: 0.72;
}
```

Existing callbacks such as `onValueChange` and `onOpenChange` should continue to work. The recommended model would be:

- use `onValueChangeAction` for mutations, persistence, server calls, async validation, or any change that can be pending
- use `onValueChange` for synchronous observation, analytics, local state updates, and compatibility with existing code
- use `useActionState` in userland when the pending state needs to affect custom button content, form status text, toasts, or other application UI

## Examples in other libraries

Astryx, Meta's design system, uses action-style APIs on components:

https://astryx.atmeta.com/

React also has first-class support for async Actions, transitions, pending state, and optimistic state:

- https://react.dev/blog/2024/12/05/react-19#actions
- https://react.dev/reference/react/useTransition
- https://react.dev/reference/react/useActionState
- https://react.dev/reference/react/useOptimistic

## Motivation

The goal is to let Base UI components participate in React's modern async interaction model instead of forcing every app to manually translate async work into component props.

This is useful for users building forms, settings screens, dashboards, menus, dialogs, and other product UI where a component change often starts async work:

- saving a preference from a switch
- changing a plan from a select
- submitting a button action
- closing a dialog after saving a draft
- changing tabs or pages that trigger async validation or persistence
- toggling a menu item that writes to a server

In these cases, the component is the place where the interaction starts, so the component should be able to participate in the async action lifecycle and guard against conflicting interactions. Without an action prop, users have to write the same `useState(false)` loading bridge repeatedly and manually remember to wire it into disabled behavior, duplicate-interaction guards, optimistic feedback, error behavior, and pending styles.

This proposal keeps Base UI headless:

- Base UI owns action invocation, deduping, semantics, accessibility, and relevant data attributes.
- Users own button loading UI and broader application pending UI through `useActionState`, normal React rendering, and CSS.
- Base UI owns optimistic display state for controls where delayed visual feedback feels broken, such as `Select`, `Checkbox`, and `Switch`.
- Error boundaries and framework error handling own failed action UI by default.

The first implementation could start with `Button` because it has the clearest action semantics, while still delegating pending rendering to the user:

```tsx
function SaveButton() {
const [state, saveAction, pending] = React.useActionState(save, null);

return {pending ? 'Saving...' : 'Save'};
}
```

The same action pattern can then be applied incrementally to high-usage change APIs such as `Switch`, `Checkbox`, `RadioGroup`, `Select`, `Tabs`, `Dialog`, and menu items. For instant-feedback value controls, the initial implementation should include internal optimistic display state so the control reflects the user's intended value immediately while the async action is pending.

Contributor guide

Open the contributing guide

Research direction

Start with the Button action proposal and compare it with the existing change-callback APIs for Switch, Checkbox, Select, Dialog, and related primitives. Read the linked React Actions, useActionState, useTransition, and useOptimistic documentation first. Done would require a decided scope and design for invocation, duplicate guards, accessibility, optimistic reconciliation, and rejected actions, with existing callbacks still working.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.