Design reactive form architecture with TanStack Form

Use TanStack Form as a transaction controller: one owner, composable sections, explicit lifecycle transitions, and subscriptions that stay fast as CRUD screens grow.

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.

Form ownershipA Business Partner form owns the draft and submit lifecycle. Composable sections bind to scoped SAP Business One fields. Services perform server validation and persistence outside UI components. Form ownerdraft · submit Identity section Group Credit + status Payments Addresses Inputs Form rules BP serviceload · validate · save
Form ownership

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.

Form lifecycleThe form moves from loading to ready and editing. Save validates and either succeeds, returns to editing with errors, or restores an acknowledged snapshot on discard. loaded edit save valid errors saved rejected discard Loading Ready Editing Validating Saving
Form lifecycle

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:

LayerExampleWhy it belongs there
Local structural ruleCardCode is present and at most 15 characters; AddressName is presentImmediate, deterministic feedback
Server-backed SAP configurationGroupCode exists and its group type fits CardType; every PaymentMethodCode is configured and activeSetup can change while the page is open
Business policyCredit increase above the company’s threshold requires approvalThis 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.

Sources

  1. TanStack Form documentation
  2. Zod documentation
  3. SAP Business One Service Layer API Reference
  4. SAP Business One: BPAddresses object
  5. SAP Business One: BPPaymentMethods object
  6. SAP Business One: CreditLimit property