Submit Button

Form submission button that automatically manages loading and disabled states based on form validation and submission status.

npx shadcn@latest add @shuip/tsf-submit-button
pnpm dlx shadcn@latest add @shuip/tsf-submit-button
bun x shadcn@latest add @shuip/tsf-submit-button
'use client';

import { Loader2Icon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useFormContext } from '@/components/ui/shuip/tanstack-form/form-context';

export interface SubmitButtonProps {
  children?: React.ReactNode;
  props?: React.ComponentProps<typeof Button>;
}

export function SubmitButton({ children = 'Submit', props }: SubmitButtonProps) {
  const form = useFormContext();
  return (
    <form.Subscribe selector={(state) => [state.isSubmitting, state.canSubmit]}>
      {([isSubmitting, canSubmit]) => (
        <Button type='submit' disabled={isSubmitting || !canSubmit} className='transition-all duration-300' {...props}>
          {isSubmitting && <Loader2Icon role='status' aria-label='Loading' className='size-4 animate-spin' />}
          {children}
        </Button>
      )}
    </form.Subscribe>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

Submit buttons need to prevent double submissions and show loading feedback. SubmitButton reads the surrounding form via useFormContext and subscribes to its state via form.Subscribe to track isSubmitting and canSubmit, automatically disabling the button when the form is invalid or already submitting.

The component displays a Loader2Icon spinner when isSubmitting is true, providing visual feedback without manual state management. It accepts a props parameter for Button variants (outline, destructive, etc.) and all native button attributes.

Because it consumes the form via context, render it inside <form.AppForm> instead of passing the form down as a prop.

Built-in features

  • Context-bound form state via useFormContext — no prop drilling
  • Auto-disabled when !canSubmit or isSubmitting
  • Loading spinner via Loader2Icon during submission
  • Button variants via props.variant and props.size

Setup

Field and form components are bound via React context. In your project, create lib/form.ts once:

// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

export const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { InputField },
  formComponents: { SubmitButton },
});

See the form-context item for details.

Usage with validation

import { useAppForm } from '@/lib/form';

const form = useAppForm({
  defaultValues: { email: '' },
  onSubmit: async ({ value }) => {
    await saveData(value);
  },
});

<form.AppField
  name='email'
  validators={{
    onChange: ({ value }) => !value.includes('@') ? 'Invalid' : undefined,
  }}
  children={(field) => (
    <field.InputField label='Email' />
  )}
/>

{/* Button disabled until email is valid */}
<form.AppForm>
  <form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>

Custom variants

<form.AppForm>
  <form.SubmitButton props={{ variant: 'outline' }}>
    Save Draft
  </form.SubmitButton>
</form.AppForm>

<form.AppForm>
  <form.SubmitButton props={{ variant: 'destructive', size: 'lg' }}>
    Delete Account
  </form.SubmitButton>
</form.AppForm>

Examples

Default

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { InputField } from '@/components/ui/shuip/tanstack-form/input-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { InputField },
  formComponents: { SubmitButton },
});

export default function TsfSubmitButtonExample() {
  const form = useAppForm({
    defaultValues: {
      email: '',
    },

    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      alert(`Subscribed: ${value.email}`);
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4 max-w-md'
    >
      <form.AppField
        name='email'
        validators={{
          onChange: ({ value }) => (!value ? 'Email is required' : !value.includes('@') ? 'Invalid email' : undefined),
        }}
        children={(field) => (
          <field.InputField
            label='Email'
            description='Subscribe to our newsletter'
            props={{ type: 'email', placeholder: 'Email' }}
          />
        )}
      />

      <form.AppForm>
        <form.SubmitButton>Subscribe</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Props

Prop

Type

On this page