Skip to content

feat(mosaic): add useForm hook - #9817

Draft
alexcarpenter wants to merge 2 commits into
mainfrom
carp/mosasaic-form-abstraction
Draft

alexcarpenter wants to merge 2 commits into
mainfrom
carp/mosasaic-form-abstraction

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Sep 17, 2026

Copy link
Copy Markdown
Member

Description

Adds useForm, the controller-layer form hook for Mosaic, under packages/mosaic/src/components/form/. It is not exported from the package yet and nothing is migrated; this PR establishes the hook and its behaviour so the profile dialogs can move to it one at a time.

useForm takes initialValues, an onSubmit, optional per-field validators, and an optional canSubmit gate. Every type is inferred from initialValues: field names, setValue value types, validator arguments, and FormSubmitError field keys.

const form = useForm({
  initialValues: { currentPassword: '', newPassword: '', confirmPassword: '' },
  fields: {
    newPassword: { validateAsync: value => validatePassword(value) },
    confirmPassword: {
      validate: (value, values) =>
        value === '' ? undefined
        : value === values.newPassword
          ? { type: 'success', message: m.match }
          : { type: 'error', message: m.mismatch },
    },
  },
  onSubmit: async values => {  },
});

form.values                    // typed from initialValues
form.fields.newPassword        // { feedback, isValidating, touched, isDirty }
form.error                     // banner message
form.isDirty                   // any field differs from its initial value
form.canSubmit                 // not submitting, no field in error or validating, canSubmit() true
form.register('newPassword') // { name, value, onChange, onBlur, ref } for a text control
form.setValue('newPassword', value)
form.touch('newPassword')
form.submit()
form.handleSubmit(event)       // preventDefault + submit, for <form onSubmit>
form.reset(values?)
Usage in a view

In a view, register wires a text control and handleSubmit wires the form element:

function EditPasswordDialog({ form }: { form: UseFormResult<EditPasswordValues> }) {
  return (
    <form id={form.id} onSubmit={form.handleSubmit}>
      {form.error ? <Banner.Root color='negative'><Banner.Label>{form.error}</Banner.Label></Banner.Root> : null}
      <PasswordField form={form} name='currentPassword' label='Current password' />
      <PasswordField form={form} name='newPassword' label='New password' />
      <PasswordField form={form} name='confirmPassword' label='Confirm password' />
      <SubmitButton form={form.id} isPending={form.isSubmitting} disabled={!form.canSubmit || !form.isDirty}>
        Save
      </SubmitButton>
    </form>
  );
}

function PasswordField({ form, name, label }: { form: UseFormResult<EditPasswordValues>; name: TextFieldName<EditPasswordValues>; label: string }) {
  const { feedback } = form.fields[name];
  return (
    <Field.Root disabled={form.isSubmitting} invalid={feedback?.type === 'error'} required>
      <Field.Label>{label}</Field.Label>
      <InputGroup.Root>
        <InputGroup.Input type='password' {...form.register(name)} />
      </InputGroup.Root>
      {feedback?.type === 'error' ? <Field.Error>{feedback.message}</Field.Error> : null}
      {feedback?.type === 'success' ? <Field.Success>{feedback.message}</Field.Success> : null}
    </Field.Root>
  );
}

register spreads name, value, onChange, onBlur and ref onto the input. When the view also needs its own ref on that input, merge them:

const { ref, ...control } = form.register(name);
const mergedRef = useMergeRefs([ref, initialFocusRef]);
<InputGroup.Input ref={mergedRef} {...control} />

A checkbox is not a text control, so it reads and writes through values and setValue:

<input
  type='checkbox'
  checked={form.values.signOutOfOtherSessions}
  onChange={event => form.setValue('signOutOfOtherSessions', event.target.checked)}
/>

Behaviour:

  • Field feedback is one shape with four levels: error, warning, success, info. Success, warning and info show immediately. An error shows only once the field is touched by blur or by any submit attempt, then updates live. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error.
  • register(name) returns the props a text control needs: name, value, onChange reading event.target.value, onBlur marking the field touched, and a ref the hook uses to focus the field on a blocked submit. It accepts only fields whose value is a string; a checkbox or select still goes through setValue.
  • isDirty on each field and on the form compares against initialValues, or against the values passed to reset(values) until the next reset().
  • A sync validate is a pure function of values, called during render, so cross-field checks need no extra wiring.
  • An async validateAsync runs when its own field changes. Latest result wins, stale results drop, and the field reports isValidating while pending. It never runs for the initial value, so opening a dialog does not trigger a check.
  • Server errors: the model rejects onSubmit with FormSubmitError(message, fields). The message lands on form.error and each field entry on that field's feedback, clearing when the field changes. A plain Error shows only its message. Anything else shows the generic message from the new form messages namespace.
  • The machine (form.machine.ts) owns only the submit lifecycle, editing → submitting → editing. Change and reset are ignored while submitting. Touched state and async validation live in the hook.

use-form.edit-password.test.ts walks the edit-password dialog end to end (async strength, confirm match, server rejection under currentPassword) and is the spec for that migration.

Not in this PR: the Form.Root, Form.Error, Form.Submit and Field.Feedback parts, Field.Root name, the swingset page, and any dialog migration.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
swingset Ready Ready Preview Sep 18, 2026 12:32pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
clerk-js-sandbox Skipped Skipped Sep 18, 2026 12:32pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 50408db

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9817

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9817

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9817

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9817

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9817

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9817

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9817

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9817

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9817

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9817

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9817

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9817

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9817

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9817

@clerk/mosaic

npm i https://pkg.pr.new/@clerk/mosaic@9817

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9817

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9817

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9817

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9817

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9817

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9817

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9817

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9817

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9817

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9817

commit: 50408db

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant