Primus Form
PrimusForm is a compound wrapper component that manages form state, validation, loading, and error display in one place. Use it when you need multi-field forms with server-side validation feedback.
For simpler forms, use individual Input, Select, and Button components directly with @primus/ui-core HTML.
Components used
Input · Select · Textarea · Button · Toggle · FormField
Code
- HTML · @primus/ui-core
- React
- Angular
<form class="vstack" style="gap:1.125rem">
<!-- Form-level error -->
<div role="alert" data-variant="danger">
Please fix the errors below before continuing.
</div>
<!-- Field with validation error -->
<div data-field="error">
<label>Admin email <span style="color:var(--danger)">*</span></label>
<input type="email" aria-invalid="true" />
<span class="error">Please enter a valid email address.</span>
</div>
<!-- Valid field -->
<label data-field>
Tenant name <span style="color:var(--danger)">*</span>
<input type="text" />
</label>
<!-- Select -->
<label data-field>
Plan
<select>
<option>Starter</option>
<option selected>Pro</option>
<option>Enterprise</option>
</select>
</label>
<!-- Toggle -->
<label>
<input type="checkbox" role="switch" checked />
Enable email notifications
</label>
<!-- Actions -->
<div style="display:flex;justify-content:flex-end;gap:0.75rem">
<button data-variant="secondary" type="reset">Cancel</button>
<button type="submit">Create tenant</button>
</div>
</form>
import {
PrimusForm, PrimusField,
PrimusInput, PrimusSelect, PrimusToggle, PrimusButton,
} from 'primus-react-ui';
const planOptions = [
{ value: 'starter', label: 'Starter' },
{ value: 'pro', label: 'Pro' },
{ value: 'enterprise', label: 'Enterprise' },
];
export function CreateTenantForm() {
const handleSubmit = async (values, { setError }) => {
try {
await createTenant(values);
navigate('/tenants');
} catch (err) {
setError(err.message);
}
};
return (
<PrimusForm
initialValues={{ name: '', email: '', plan: 'pro', notifications: true }}
validate={(values) => {
const errors: Record<string, string> = {};
if (!values.name) errors.name = 'Tenant name is required';
if (!values.email) errors.email = 'Email is required';
return errors;
}}
onSubmit={handleSubmit}
>
{({ values, errors, formError, isLoading, setFieldValue }) => (
<>
{formError && (
<div role="alert" data-variant="danger">{formError}</div>
)}
<PrimusField label="Tenant name" error={errors.name} required>
<PrimusInput value={values.name}
onChange={(v) => setFieldValue('name', v)} />
</PrimusField>
<PrimusField label="Admin email" error={errors.email} required>
<PrimusInput type="email" value={values.email}
onChange={(v) => setFieldValue('email', v)} />
</PrimusField>
<PrimusSelect label="Plan" value={values.plan}
options={planOptions}
onChange={(v) => setFieldValue('plan', v)} />
<PrimusToggle label="Enable email notifications"
checked={values.notifications}
onChange={(v) => setFieldValue('notifications', v)} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<PrimusButton variant="secondary"
onClick={() => navigate(-1)}>Cancel</PrimusButton>
<PrimusButton type="submit" loading={isLoading}>
Create tenant
</PrimusButton>
</div>
</>
)}
</PrimusForm>
);
}
// create-tenant.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({ templateUrl: './create-tenant.component.html' })
export class CreateTenantComponent {
form: FormGroup;
formError = '';
isLoading = false;
planOptions = [
{ value: 'starter', label: 'Starter' },
{ value: 'pro', label: 'Pro' },
{ value: 'enterprise', label: 'Enterprise' },
];
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
plan: ['pro'],
notifications: [true],
});
}
fieldError(field: string): string {
const c = this.form.get(field);
if (!c?.touched || !c?.errors) return '';
if (c.errors['required']) return `${field} is required`;
if (c.errors['email']) return 'Please enter a valid email';
return '';
}
async onSubmit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.isLoading = true;
this.formError = '';
try {
await this.tenantSvc.create(this.form.value);
this.router.navigate(['/tenants']);
} catch (err: any) {
this.formError = err.message;
} finally {
this.isLoading = false;
}
}
}
<!-- create-tenant.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="vstack" style="gap:1.125rem">
<div role="alert" data-variant="danger" *ngIf="formError">
{{ formError }}
</div>
<primus-input label="Tenant name" [required]="true"
formControlName="name" [error]="fieldError('name')">
</primus-input>
<primus-input label="Admin email" type="email" [required]="true"
formControlName="email" [error]="fieldError('email')">
</primus-input>
<primus-select label="Plan" [options]="planOptions"
formControlName="plan">
</primus-select>
<primus-toggle label="Enable email notifications"
formControlName="notifications">
</primus-toggle>
<div style="display:flex;justify-content:flex-end;gap:0.75rem">
<primus-button variant="secondary" type="button"
(clicked)="onCancel()">Cancel</primus-button>
<primus-button type="submit" [loading]="isLoading">
Create tenant
</primus-button>
</div>
</form>
Props — PrimusForm (React)
| Prop | Type | Default | Description |
|---|---|---|---|
initialValues | Record<string, any> | {} | Initial form field values |
validate | (values) => Record<string, string> | — | Sync validation function, returns field error map |
onSubmit | (values, helpers) => Promise<void> | required | Submit handler. helpers provides setError, setFieldErrors |
children | (bag) => ReactNode | required | Render prop receiving form state |
Form bag (render prop)
interface FormBag {
values: Record<string, any>;
errors: Record<string, string>; // field-level errors
touched: Record<string, boolean>;
formError: string; // form-level error
isLoading: boolean;
setFieldValue: (field: string, value: any) => void;
setError: (message: string) => void;
resetForm: () => void;
}
Angular: use Reactive Forms
Angular uses the built-in ReactiveFormsModule directly. Primus form components (Input, Select, Toggle) all support formControlName — no extra wrapper needed.
// Validators
import { Validators } from '@angular/forms';
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
seats: [10, [Validators.min(1), Validators.max(500)]],
});
// Show field error
fieldError(field: string): string {
const c = this.form.get(field);
if (!c?.touched || !c?.errors) return '';
if (c.errors['required']) return 'This field is required';
if (c.errors['email']) return 'Enter a valid email';
if (c.errors['min']) return 'Minimum value is 1';
return 'Invalid value';
}
Version history
See the Changelog for version history and breaking changes.