name: forms description: Use when building, editing, or adding forms in the Carbon ERP/MES codebase - covers ValidatedForm, zod validators, form components, and action handlers
Carbon Forms
Forms in Carbon follow a three-part pattern: zod validator in the module's .models.ts, form component in the module's ui/ directory, and action handler in the route file.
File Locations
| Piece | ERP Location | MES Location |
|---|---|---|
| Validator | app/modules/{module}/{module}.models.ts |
app/services/models.ts |
| Form UI | app/modules/{module}/ui/{Feature}/{Feature}Form.tsx |
Inline in route or app/components/ |
| Route action | app/routes/x+/{module}+/{resource}.new.tsx |
app/routes/x+/{resource}.tsx |
| Form components | ~/components/Form (re-exports @carbon/form + domain selectors) |
@carbon/form directly |
1. Validator (zod schema)
Define in the module's .models.ts. Use z from zod and zfd from zod-form-data.
import { z } from "zod";
import { zfd } from "zod-form-data";
export const thingValidator = z.object({
id: zfd.text(z.string().optional()), // optional ID for create/edit
name: z.string().min(1, { message: "Name is required" }),
type: z.enum(thingTypes, { // enum with custom error
errorMap: () => ({ message: "Type is required" })
}),
quantity: zfd.numeric(z.number().min(0)), // numeric from FormData
isActive: zfd.checkbox(), // checkbox boolean
notes: zfd.text(z.string().optional()), // optional text
items: z.array(z.string().min(1)).min(1, { // required array
message: "At least one item is required"
}),
});
Key rules:
- Use
zfd.text()for optional strings from FormData - Use
zfd.numeric()for numbers from FormData - Use
zfd.checkbox()for boolean checkboxes - Use
z.enum()witherrorMapfor enum fields - Use
.refine()for cross-field validation
2. Form Component
The core of any form is ValidatedForm wrapping your fields. The surrounding container varies by context — Drawers, Cards, inline sections, modals, etc. Look at neighboring routes to match the existing pattern.
Import form fields from ~/components/Form (ERP) or @carbon/form (MES).
import { ValidatedForm } from "@carbon/form";
import { Button, HStack, VStack } from "@carbon/react";
import { useNavigate } from "react-router";
import type { z } from "zod";
import { Hidden, Input, Select, Submit } from "~/components/Form";
import { usePermissions } from "~/hooks";
import { thingValidator } from "~/modules/things";
import { path } from "~/utils/path";
type ThingFormProps = {
initialValues: z.infer<typeof thingValidator>;
};
const ThingForm = ({ initialValues }: ThingFormProps) => {
const permissions = usePermissions();
const navigate = useNavigate();
const onClose = () => navigate(-1);
const isEditing = !!initialValues.id;
const isDisabled = isEditing
? !permissions.can("update", "things")
: !permissions.can("create", "things");
return (
<ValidatedForm
validator={thingValidator}
method="post"
action={isEditing ? path.to.thing(initialValues.id!) : path.to.newThing}
defaultValues={initialValues}
>
<Hidden name="id" />
<VStack spacing={4}>
<Input name="name" label="Name" />
<Select name="type" label="Type" options={typeOptions} />
</VStack>
<HStack>
<Submit isDisabled={isDisabled}>Save</Submit>
<Button size="md" variant="solid" onClick={onClose}>Cancel</Button>
</HStack>
</ValidatedForm>
);
};
Key rules:
- Always include
<Hidden name="id" />for edit support - Use
VStack spacing={4}for vertical field layout - Use
grid grid-cols-1 lg:grid-cols-3 gap-x-8 gap-y-4for multi-column layouts - Permission check determines
isDisabledon Submit - Type props with
z.infer<typeof validator>
3. Route Action
import { assertIsPost, error, success } from "@carbon/auth";
import { requirePermissions } from "@carbon/auth/auth.server";
import { flash } from "@carbon/auth/session.server";
import { validationError, validator } from "@carbon/form";
import type { ActionFunctionArgs } from "react-router";
import { data, redirect } from "react-router";
import { thingValidator, insertThing } from "~/modules/things";
import { path } from "~/utils/path";
export async function action({ request }: ActionFunctionArgs) {
assertIsPost(request);
const { client, companyId, userId } = await requirePermissions(request, {
create: "things"
});
const validation = await validator(thingValidator).validate(
await request.formData()
);
if (validation.error) {
return validationError(validation.error);
}
const result = await insertThing(client, {
...validation.data,
companyId,
createdBy: userId
});
if (result.error) {
return data({}, await flash(request, error(result.error, "Failed to create thing")));
}
throw redirect(path.to.things, await flash(request, success("Thing created")));
}
Key rules:
assertIsPost(request)firstrequirePermissionswith appropriate module/actionvalidator(schema).validate(formData)- NOTschema.parse()- Return
validationError(validation.error)on failure (422 status) throw redirect()on success (notreturn redirect())- Return plain objects from actions, never
Response.json()
4. Route Default Export
export default function NewThingRoute() {
const initialValues = {
id: "",
name: "",
type: "Default" as const,
};
return <ThingForm initialValues={initialValues} />;
}
For edit routes, load data in the loader and pass to the form:
export default function EditThingRoute() {
const { thing } = useLoaderData<typeof loader>();
return <ThingForm initialValues={thing} />;
}
Available Form Components
From @carbon/form (base):
| Component | Props | Use for |
|---|---|---|
Input |
name, label, prefix?, suffix?, helperText? |
Text fields |
Number |
name, label, formatOptions? |
Numeric fields with steppers |
TextArea |
name, label, characterLimit? |
Multi-line text |
Select |
name, label, options: {label, value}[] |
Dropdown |
Combobox |
name, label, options: {label, value}[] |
Searchable dropdown |
CreatableCombobox |
name, label, options, onCreateOption? |
Searchable + create new |
MultiSelect |
name, label, options |
Multi-select |
Boolean |
name, label, description? |
Switch/toggle |
DatePicker |
name, label, minValue?, maxValue? |
Date selection |
DateTimePicker |
name, label |
Date + time |
TimePicker |
name, label |
Time only |
Hidden |
name, value? |
Hidden fields |
Password |
name, label |
Password with toggle |
Radios |
name, label, options, orientation? |
Radio buttons |
Submit |
isDisabled?, withBlocker? |
Submit with unsaved changes warning |
Array |
name, label |
Dynamic list fields |
From ~/components/Form (ERP domain selectors):
Customer, Supplier, Employee, Employees, Users, Item, Part, Location, Account, AccountCategory, AccountSubcategory, Currency, Department, WorkCenter, UnitOfMeasure, PaymentTerm, ShippingMethod, Shift, Sequence, Process, Procedure, Tool, Tags, CustomFormFields
These are Combobox/CreatableCombobox wrappers that auto-load options from stores. Use them instead of raw Combobox when the entity type matches.
Common Patterns
Dependent fields (value of one field changes options of another):
const [categoryId, setCategoryId] = useState(initialValues.categoryId ?? "");
<AccountCategory name="categoryId" onChange={(cat) => setCategoryId(cat?.id ?? "")} />
<AccountSubcategory name="subcategoryId" accountCategoryId={categoryId} />
Enum options from const array:
const typeOptions = thingTypes.map((t) => ({ label: t, value: t }));
<Select name="type" label="Type" options={typeOptions} />
Client action for cache invalidation:
export async function clientAction({ serverAction }: ClientActionFunctionArgs) {
const companyId = getCompanyId();
window.clientCache?.invalidateQueries({
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey[0] === "things" && queryKey[1] === companyId;
}
});
return await serverAction();
}
Checklist
When building a new form:
- Define zod validator in
{module}.models.ts - Export validator from module index
- Create form component in
ui/{Feature}/{Feature}Form.tsx - Create route file with action + default export
- Add
clientActionif the entity is cached client-side - Add path helpers in
~/utils/pathif needed - Check neighboring routes to match the container pattern (Drawer, Card, inline, etc.)