The architecture decision
TanStack Form is useful in enterprise CRUD because it makes the form state machine explicit without requiring every section to be a bespoke controller. It is not a reason to put all business logic in a hook. The form owns a draft and its lifecycle; services own I/O; sections own field layout; schemas own data rules.
For a SAP Business One Business Partner editor, one owner should coordinate identity, contact and tax fields, Business Partner Group selection, payment methods, addresses, and credit status because the user saves one BusinessPartners record. A group selector remains reusable because it receives GroupCode and does not know which endpoint persists the Business Partner.
Who this is for
Senior frontend engineers, technical leads, and architects building React CRUD features with nested data, dynamic sections, or non-trivial validation.
Decision checklist
- Does one form instance match one business transaction?
- Can each section bind to a narrow field path without importing the parent feature?
- Are default values complete enough that fields are controlled from the first render?
- Are server queries and mutations outside reusable fields and sections?
- Does Save use the complete draft and Discard restore the acknowledged snapshot?
- Are subscribers scoped to the smallest state slice they need?
A form lifecycle worth naming
The common lifecycle is loading → ready → editing → validating → saving → saved | failed. A dirty draft is not merely “different values”; it is different from the last server-acknowledged snapshot. That definition makes reset, discard, and post-save behavior predictable.
Compose data first, then components
Use a data shape that mirrors the transaction. It gives nested objects and arrays a predictable home.
const businessPartnerDefaults = {
CardCode: '',
CardName: '',
CardType: 'cCustomer',
GroupCode: undefined as number | undefined,
Phone1: '',
Phone2: '',
Cellular: '',
EmailAddress: '',
FederalTaxID: '',
VatIDNum: '',
PayTermsGrpCode: undefined as number | undefined,
CreditLimit: 0,
Frozen: 'tNO',
CurrentAccountBalance: 0,
BPPaymentMethods: [{ PaymentMethodCode: '' }],
BPAddresses: [{
AddressName: '',
AddressType: 'bo_BillTo',
Street: '',
City: '',
Country: '',
}],
};
const form = useForm({
defaultValues: businessPartnerDefaults,
validators: { onSubmit: businessPartnerSchema },
onSubmit: async ({ value }) => {
const saved = await businessPartnerService.save(toBusinessPartnerPayload(value));
form.reset(saved);
},
});
The example deliberately keeps persistence in businessPartnerService. The identity/contact section owns CardCode, CardName, Phone1, Phone2, Cellular, and EmailAddress; the tax section owns FederalTaxID and VatIDNum; a group selector renders GroupCode; and the collection sections render BPPaymentMethods and BPAddresses. CurrentAccountBalance is an SAP B1 open-balance value, so it is display state, not an editable client-owned value.
BPAddresses is not a single street field. It is a list of Bill-to, Pay-to, or Ship-to records, where AddressName is mandatory. Treat it as an array with stable client keys for rendering, but send SAP’s address rows in the Service Layer payload. Likewise, BPPaymentMethods is a child collection: adding a payment method changes the Business Partner transaction, while defining a payment method itself remains separate banking setup master data.
Encode SAP B1 rules at the right boundary
The form should not pretend all rules are equal. Keep three layers visible:
| Layer | Example | Why it belongs there |
|---|---|---|
| Local structural rule | CardCode is present and at most 15 characters; AddressName is present | Immediate, deterministic feedback |
| Server-backed SAP configuration | GroupCode exists and its group type fits CardType; every PaymentMethodCode is configured and active | Setup can change while the page is open |
| Business policy | Credit increase above the company’s threshold requires approval | This is a company decision, not a field-format rule |
const businessPartnerDraftSchema = z.object({
CardCode: z.string().trim().min(1, 'CardCode is required.').max(15),
CardName: z.string().trim().min(1, 'CardName is required.').max(100),
CardType: z.enum(['cCustomer', 'cSupplier', 'cLid']),
GroupCode: z.number().int().positive().optional(),
Phone1: z.string().max(50),
EmailAddress: z.string().email('Enter a valid EmailAddress.').or(z.literal('')),
FederalTaxID: z.string().max(32),
VatIDNum: z.string(),
PayTermsGrpCode: z.number().int().positive().optional(),
CreditLimit: z.number().finite().min(0),
Frozen: z.enum(['tYES', 'tNO']),
BPAddresses: z.array(z.object({
clientRowId: z.string(),
AddressName: z.string().trim().min(1, 'AddressName is required.'),
AddressType: z.enum(['bo_BillTo', 'bo_ShipTo']),
Street: z.string(), City: z.string(), Country: z.string().length(2),
})),
BPPaymentMethods: z.array(z.object({ PaymentMethodCode: z.string().trim().min(1) })),
}).superRefine((bp, ctx) => {
const names = new Set<string>();
bp.BPAddresses.forEach((address, index) => {
const key = `${address.AddressType}:${address.AddressName.toLocaleLowerCase()}`;
if (names.has(key)) {
ctx.addIssue({
code: 'custom',
path: ['BPAddresses', index, 'AddressName'],
message: 'Address name must be unique within its type.',
});
}
names.add(key);
});
if (bp.Frozen === 'tYES' && bp.CreditLimit > 0) {
ctx.addIssue({
code: 'custom',
path: ['CreditLimit'],
message: 'Review credit exposure before freezing this Business Partner.',
});
}
});
CreditLimit deserves a separate decision. SAP B1 can populate it from the payment-terms group (PayTermsGrpCode); do not silently recalculate it in the browser. Display the server-provided value, let an authorized user propose a change, and record an approval decision when the organization requires one.
For dynamic sections, use stable domain IDs for rendered rows whenever possible. An editable SAP B1 document-line grid needs an explicit temporary client ID for a new row; array position is not a durable identity after sorting, deletion, or server reconciliation.
Validation across sections without coupling sections
Local schemas validate Business Partner identity/contact/tax fields, a Business Partner Group selection, one BPAddresses row, or a BPPaymentMethods row. The transaction owner adds rules that need the full draft: a CardType may constrain available GroupCode values; a CreditLimit above a threshold may require an approver; a Ship-to address may be required before a sales-document workflow. Keep these rules at the form boundary instead of making BusinessPartnerGroupSelector or AddressList import CreditSection.
External state should arrive as an explicit dependency. For example, an SAP B1 sales-order DocumentLines rule can receive a current ItemCode price snapshot and return “UnitPrice changed; review line” rather than silently fetching inside a field validator. This makes timing, tests, and stale-data handling visible.
Async validators need a current-value guard. Debounce typing when appropriate, abort or ignore superseded requests, and only apply a response if it still belongs to the value being checked. The server remains the final authority during Save.
The server-backed checks can run once the local shape is valid:
async function validateSapConfiguration(draft: BusinessPartnerDraft, signal: AbortSignal) {
const [group, methods] = await Promise.all([
draft.GroupCode ? sap.businessPartnerGroups.get(draft.GroupCode, { signal }) : undefined,
sap.paymentMethods.getActive({ cardType: draft.CardType, signal }),
]);
if (group && !isCompatibleGroupType(group.Type, draft.CardType)) {
return {
GroupCode: 'This group does not match the selected CardType.',
};
}
const allowed = new Set(methods.map((method) => method.PaymentMethodCode));
const invalid = draft.BPPaymentMethods.find((method) => !allowed.has(method.PaymentMethodCode));
return invalid
? { BPPaymentMethods: 'Select a currently configured payment method.' }
: undefined;
}
Subscriptions are a performance boundary
Do not subscribe an editable grid to the entire form state just to show one row error. Subscribe each row to its own value and error state, and subscribe a footer only to canSubmit, isSubmitting, and dirty status. This limits rendering work and makes performance an architectural property instead of an emergency optimization.
Avoid selector cleverness when a screen is small. Add narrow subscriptions where measurement shows broad updates or where a large dynamic collection makes the dependency obvious.
Save and discard orchestration
Save is an orchestration concern: prevent duplicate submission, validate, call the service, map server errors, replace the acknowledged snapshot only on success, and expose a retryable failure state. Discard is a deliberate reset to that snapshot, usually after confirmation when the draft is dirty. Neither belongs in an input component.
The form can solve most CRUD screens cleanly. It should not become a workflow engine for multi-day approvals, offline conflict resolution, or a distributed order process. Those need a workflow model beyond client draft state.
Key takeaways
- Make the form controller the owner of one business transaction.
- Compose sections through field paths and data contracts, not parent component knowledge.
- Put full-draft rules at the transaction boundary and I/O in services.
- Treat dirty, saving, saved, and failed as named lifecycle states.
- Narrow subscriptions where a grid or dynamic collection would otherwise observe everything.
Conclusion
TanStack Form works best when the architecture already has clear ownership. Its value is not fewer lines of JSX; it is a reliable place to coordinate draft state, validation, and lifecycle while allowing sections to evolve independently.