EG
Resume
·frontend·
#ant-design#zod#react#forms#typescript

dynamic forms are hard. like, really hard. here's how I approached building forms that adapt to business rules without losing user sanity. 😮‍💨

The Problem

in PAMAFix SAP, forms change based on:

  • User role
  • Transaction type
  • Amount thresholds
  • Supplier category
  • Mining site
  • Tax classification
  • ...and like 10 more things

building static forms wasn't an option. we needed dynamic.

The Stack

Ant Design (AntD)

  • Great table components
  • Form components are solid
  • Built-in validation (but we added more)
  • Enterprise-grade look

Zod

  • Runtime validation
  • Type inference
  • Composable schemas
  • Error messages that make sense

Basic Setup

import { z } from 'zod';
import { Form, Input, Select } from 'antd';

// Define your schema
const TransactionSchema = z.object({
  type: z.enum(['purchase', 'expense', 'transfer']),
  amount: z.number().positive(),
  description: z.string().min(10),
  category: z.string(),
});

// Infer the type
type Transaction = z.infer<typeof TransactionSchema>;

Dynamic Field Rendering

interface FieldConfig {
  name: string;
  type: 'input' | 'select' | 'date' | 'number';
  label: string;
  rules?: any[];
  visibleWhen?: Record<string, any>; // show based on other field values
  options?: { label: string; value: string }[];
}

const DynamicField: React.FC<{ config: FieldConfig; control: any }> = ({
  config,
  control,
}) => {
  if (!config.visibleWhen && !evaluateCondition(config.visibleWhen, control)) {
    return null;
  }

  switch (config.type) {
    case 'input':
      return <Input {...control} label={config.label} />;
    case 'select':
      return (
        <Select {...control} label={config.label}>
          {config.options?.map(opt => (
            <Select.Option key={opt.value} value={opt.value}>
              {opt.label}
            </Select.Option>
          ))}
        </Select>
      );
    case 'number':
      return <InputNumber {...control} label={config.label} />;
    default:
      return null;
  }
};

Conditional Validation

const PurchaseOrderSchema = z.object({
  isInternational: z.boolean(),
  supplierCountry: z.string().optional(),
  importLicense: z.string().optional(),
}).refine(
  (data) => {
    // If international, supplier country is required
    if (data.isInternational && !data.supplierCountry) {
      return false;
    }
    return true;
  },
  {
    message: "Supplier country is required for international purchases",
    path: ["supplierCountry"],
  }
);

// Chain with other validations
const CompletePOSchema = PurchaseOrderSchema.merge(
  z.object({
    items: z.array(LineItemSchema).min(1),
    paymentTerms: z.enum(['prepaid', 'net30', 'net60']),
  })
);

Building Forms Dynamically

const buildFormSchema = (transactionType: string, userRole: string) => {
  let schema = baseTransactionSchema;

  // Add role-specific fields
  if (userRole === 'finance_manager') {
    schema = schema.merge(financeManagerFields);
  }

  // Add type-specific validation
  if (transactionType === 'purchase') {
    schema = schema.merge(purchaseOrderFields);
  } else if (transactionType === 'expense') {
    schema = schema.merge(expenseFields);
  }

  return schema;
};

const DynamicTransactionForm: React.FC<{
  transactionType: string;
  userRole: string;
}> = ({ transactionType, userRole }) => {
  const schema = useMemo(
    () => buildFormSchema(transactionType, userRole),
    [transactionType, userRole]
  );

  return (
    <Form>
      <FormFields schema={schema} />
    </Form>
  );
};

Handling Submit with Zod

const handleSubmit = async (values: unknown) => {
  try {
    // Validate with Zod
    const validated = CompletePOSchema.parse(values);

    // Submit to API
    await transactionService.create(validated);

    message.success('Transaction created successfully!');
    form.reset();
  } catch (error) {
    if (error instanceof z.ZodError) {
      // Convert Zod errors to AntD form errors
      const fieldErrors: Record<string, string> = {};
      error.errors.forEach((err) => {
        const path = err.path.join('.');
        fieldErrors[path] = err.message;
      });
      form.setFields(fieldErrors);
    } else {
      message.error('Something went wrong');
    }
  }
};

Complex Conditional Logic

// Real-world example: tax calculation changes based on multiple factors
const calculateTax = (
  amount: number,
  transactionType: string,
  supplierCategory: string,
  isPpnObligation: boolean
) => {
  if (isPpnObligation && transactionType === 'purchase') {
    return {
      ppn: amount * 0.11,
      ppnType: 'inclusive',
    };
  }

  if (supplierCategory === 'mining_services') {
    return {
      pph23: amount * 0.02,
      ppnType: 'exempted',
    };
  }

  return {
    ppn: 0,
    ppnType: 'none',
  };
};

Error Handling UX

don't just show "validation error". tell users WHAT to fix:

const getErrorMessage = (error: ZodError, field: string) => {
  const fieldError = error.errors.find((e) => e.path.join('.') === field);
  return fieldError?.message || null;
};

// In component
<Form.Item
  name="amount"
  validateStatus={errors.amount ? 'error' : ''}
  help={errors.amount || ''}
>
  <InputNumber />
</Form.Item>

Key Takeaways

  1. Schema is the source of truth - define once, validate everywhere
  2. Separate config from components - makes testing easier
  3. Conditional logic is inevitable - embrace it early
  4. Error messages matter - users shouldn't guess what's wrong
  5. TypeScript + Zod = powerful combo - compile time + runtime safety

dynamic forms are never "done". business rules change, users request features, edge cases appear. build for flexibility.

Related Articles