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
- Schema is the source of truth - define once, validate everywhere
- Separate config from components - makes testing easier
- Conditional logic is inevitable - embrace it early
- Error messages matter - users shouldn't guess what's wrong
- 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
InertiaJS + React: The Best of Both Worlds
What is InertiaJS and how to integrate it with React in a Laravel app. Installation, routing, performance tips, and debugging explained.
NestJS Dependency Injection Demystified
Finally understanding why @Inject() decorator exists and how DI actually works in NestJS.
TypeScript Utility Types You'll Actually Use
Forget the obscure ones - here are the TypeScript utility types I use every day.