FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Action props

FUNSTACK UI has no onClick, onChange or onValueChange. Interactive props are actions: callbacks that the component runs inside its own transition and awaits.

Naming

A controllable state x always comes as a triple: x (controlled), defaultX (uncontrolled initial value) and xChangeAction.

ComponentStateAction
Togglepressed / defaultPressedpressedChangeAction
Tabsvalue / defaultValuevalueChangeAction
Accordionvalue / defaultValuevalueChangeAction
Tooltipopen state is browser-ownednone
Dialogopen / defaultOpenopenChangeAction (awaited on open, a notification on close)
Switch, Checkboxchecked / defaultCheckedcheckedChangeAction
RadioGroupvalue / defaultValuevalueChangeAction
CheckboxGroupvalue / defaultValue (a set as string[])valueChangeAction

A part that performs one task rather than changing a state takes a plain action (Dialog.Close action: run this, then close). A state that the consumer derives and the library only mirrors is a controlled-only prop with no default and no action (Checkbox indeterminate).

undefined always means "uncontrolled"; "nothing selected" is null. Switching between controlled and uncontrolled after mount is not supported (development warning).

Signature

type Action<Args extends readonly unknown[] = []> = (...args: Args) => unknown;

interface ActionDetails {
  /** The DOM event that caused the action, or undefined. */
  readonly event: Event | undefined;
}

type ChangeAction<T, Details extends ActionDetails = ActionDetails> =
  Action<[next: T, details: Details]>;

The first argument is the next value, so the common case stays short. The second is a details object with the native DOM event (not React's synthetic one) and any component-specific fields, such as reason (below) or Dialog's returnValue. The return type is unknown: return a promise and the component awaits it, return anything else and it is ignored. That means a function that returns data (a Server Function, a fetch) type-checks without wrapping.

<Toggle pressedChangeAction={(pressed) => api.setFavorite(id, pressed)}>Favorite</Toggle>
<Tabs.Root defaultValue="general" valueChangeAction={(tab) => analytics.track(tab)}>...</Tabs.Root>

details.reason

Components that can change for more than one reason say why, with one library-wide vocabulary (the public type ChangeReason):

reasonMeaningdetails.event
"activate"The user acted on one of the component's parts: a click, Enter or Space, focus in automatic activation, a Dialog Close button or form submit.The native click, keydown or focusin event. For checkboxes and radios always the click, including Space and arrow keys.
"platform"The browser changed the state by itself: find-in-page, a form reset, or an interaction before hydration found on mount. Never a user gesture.The platform event (beforematch, toggle, reset), or undefined on mount.
"dismiss"The user closed the component without one of its parts: Escape, Android back, an assistive-technology dismiss, a click outside.The platform's close event.

Tabs, Accordion, Switch, Checkbox and RadioGroup dispatch "activate" and "platform"; Dialog uses all three. A "platform" dispatch goes through the same action as a user one, so a controlled parent hears about a form reset and decides whether to adopt it. See Browser-owned state.

What happens on a click

The component does, in one transition of its own:

startTransition(async () => {
  addTransitionType("fsui-toggle");     // plus fsui-toggle-press / -release
  setOptimistic(next);                  // the button flips immediately
  await pressedChangeAction?.(next, details);
  // uncontrolled: commit `next` now, if this is still the latest dispatch
});

What you can rely on:

  • Sync or async. The action is awaited; data-pending stays on until the promise settles and the resulting render has committed (including a Suspense wait).
  • The synchronous part runs during the event. details.event.preventDefault() works there, and a setState there is a transition update.
  • Disabled parts never dispatch for user interaction, and no-op interactions (clicking the tab that is already selected) do not dispatch either. (A form reset still reaches a disabled Switch, Checkbox or RadioGroup: the browser resets disabled inputs too, and the form's author asked for it.)
  • Parts are never disabled while pending. The library only sets data-pending; a second click is a new intent (below). If you want to block it, use CSS: [data-pending] { pointer-events: none }.

After an await, wrap state updates yourself

This is a React rule, and the library cannot hide it: a setState that runs after your own await is no longer part of the transition. Wrap it in startTransition.

// Controlled, local state: set it in the synchronous part.
<Tabs.Root value={tab} valueChangeAction={(next) => { setTab(next); }}>

// Controlled, server-backed: adopt the value on success, re-wrapped after the await.
<Tabs.Root
  value={tab}
  valueChangeAction={async (next) => {
    await saveTab(next);
    startTransition(() => setTab(next));
  }}
>

Await only what the UI must wait for

While an async action is pending, React holds every transition in the app that started meanwhile and commits them together. A two-second awaited save in one Toggle also delays a router navigation. Background work that the UI need not wait for should not be returned:

// The panel switches immediately; the save runs in the background.
valueChangeAction={(next) => { void saveSelection(next); }}

// The panel switches when the save succeeds; the tab shows pending meanwhile.
valueChangeAction={async (next) => { await saveSelection(next); }}

Errors go to Error Boundaries

An action that throws or rejects reaches the nearest Error Boundary above the component that owns the transition (the Root, or the Toggle itself). The library never swallows an error. The optimistic state reverts automatically, and uncontrolled state is only written after success, so a failed action leaves it untouched.

For inline error UI instead, catch inside the action. A caught error makes the action resolve, which counts as success: in uncontrolled mode the new value is committed. Use controlled mode when the parent must be able to veto.

Show code
ToggleErrors.tsx
"use client";
import { startTransition, useRef, useState } from "react";
import { Toggle } from "@funstack/ui/toggle";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";

// Two ways to handle a failing action.
export function ToggleErrors() {
  return (
    <div className="demo-row">
      <ControlledWithInlineError />
      <DemoErrorBoundary>
        <Toggle
          className="demo-toggle"
          pressedChangeAction={(next) => fakeSave(`notify=${next}`, { fail: true })}
        >
          {({ pending }) => (
            <>{pending && <span className="demo-spinner" aria-hidden />}Notify me</>
          )}
        </Toggle>
      </DemoErrorBoundary>
    </div>
  );
}

// Controlled, "update on success": the parent adopts the value only after the save succeeds.
// Every second save fails; the error is caught inside the action, so the Toggle snaps back.
function ControlledWithInlineError() {
  const [pinned, setPinned] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const saves = useRef(0);
  return (
    <span>
      <Toggle
        className="demo-toggle"
        pressed={pinned}
        pressedChangeAction={async (next) => {
          setError(null); // sync prefix: already a Transition update
          try {
            await fakeSave(`pinned=${next}`, { fail: ++saves.current % 2 === 0 });
            // After an await, wrap state updates in startTransition yourself.
            startTransition(() => setPinned(next));
          } catch (e) {
            startTransition(() => setError(e instanceof Error ? e.message : String(e)));
          }
        }}
      >
        {({ pending }) => <>{pending && <span className="demo-spinner" aria-hidden />}Pin</>}
      </Toggle>
      {error && <span className="demo-inline-error"> {error}</span>}
    </span>
  );
}

Overlapping actions: the latest intent wins

React neither cancels nor orders overlapping actions. The components do this: the optimistic state always shows the most recent click, and uncontrolled state is committed only for the most recently dispatched action, even if an older one resolves later. There is no queue and no abort signal; if you need to cancel a request, keep an AbortController in a ref inside your action.

React would silently drop the rejection of a superseded (older) action. The library catches it and rethrows it from render, so it still reaches the same Error Boundary as a failure of the latest action.

Server Functions work directly

A Server Function ("use server") can be passed straight to any action prop, from a Server Component or a Client Component. details is a plain object of serialisable fields; event is served by a proxy and is not an own property, so React's serialiser never sees it. The server receives (next, details) with details.event undefined and every other field intact.

// app/post.tsx: a Server Component
import { Toggle } from "@funstack/ui/toggle";
import { setFavorite } from "./actions"; // "use server"

<Toggle defaultPressed={post.favorited} pressedChangeAction={setFavorite.bind(null, post.id)}>
  Favorite
</Toggle>

Costs of the virtual event

Object.keys(details), spreading and JSON.stringify do not include event; "event" in details and details.event do. structuredClone(details) throws. In tests, assert details.event directly rather than toEqual({ event }).

Form actions are a separate channel

<form action>, useFormStatus and React's automatic form reset are React's form channel. A control's change action is per control and runs in the control's own transition. The two are independent:

  • A Switch, Checkbox or RadioGroup inside <form action={serverFn}> needs no change action: with a name it posts its value like any native input, and useFormStatus().pending covers the submit.
  • A control never folds useFormStatus into its own pending: that would mark every control in the form pending during a submit. Read useFormStatus in your own className function, or style form:has(...).
  • "Autosave on change" is checkedChangeAction={saveSetting}; "submit on change" is checkedChangeAction={() => formRef.current?.requestSubmit()}.
  • A part may observe the form channel for a platform side effect: Dialog.Form closes its dialog once the form action has settled. It never wraps your action, so a Server Function keeps React's progressive enhancement.
  • While a form action is pending, a control's transition is entangled with it, and the reverse: React commits them together.

What about native handlers?

DOM event props that a part does not use itself pass through to the element (an onPointerEnter on a Tab, an onClick on a Tooltip trigger). They are the platform's API, not the library's. Handlers the part does install (for example onClick on a Toggle) are removed from its props type so they cannot be clobbered by accident.