If you’ve spent enough time building frontend applications, you eventually hit a wall where the standard “todo-app” tutorials stop helping.
For me, that wall appeared while working on a massive, multi-tenant enterprise healthcare dashboard. We were using the holy trinity of modern React forms: React Hook Form, Zod, and TypeScript. It’s a beautiful stack — until your product manager drops a requirement that completely breaks the standard validation mental model.
The requirement was simple on paper: “Users cannot book appointments in the past. Unless, of course, their specific clinic has the allowPastAppointments configuration turned on for retrospective paper-data entry."
Suddenly, validation isn’t a universal truth anymore. It’s dynamic. It depends on the global state of the application.
Here is the problem: Standard Zod schemas are blind to React state. Because we define our schemas outside of the React component lifecycle to avoid unnecessary re-renders, the schema has no idea what useConfig(), Redux, or Zustand is doing.
In this article, I’ll walk you through exactly how to solve this using the Schema Factory pattern. No messy if/else blocks inside your submit handlers, no breaking your TypeScript inference, and no compromising your architecture.
The Anti-Pattern: How Not to Do It
When faced with dynamic validation, the instinct for many developers is to bypass Zod entirely for that specific rule. They let the schema handle the basics (like required fields), but shove the business logic into the onSubmit handler:
const onSubmit = (data) => {
// ❌ The Anti-Pattern
if (!allowPastAppointments && selectedTime < now) {
setError('startTime', { message: 'Time cannot be in the past' });
return;
}
saveData(data);
};
Why is this bad? Because you’ve just fractured your validation logic. Half of it lives in Zod, and half of it lives in your component. If another component needs to use this form, you have to copy-paste the if statement. It’s a nightmare to test and even harder to maintain.
We need Zod to handle everything. We just need to teach it how to read our global state.
Step 1: The Schema Factory
By default, a Zod schema is a static object. To make it dynamic, we need to transform it into a “machine” that builds a custom schema on the fly, depending on the raw materials (variables) we feed it.
We do this by wrapping the schema in a factory function.
Instead of this:
export const bookingSchema = z.object({ ... });
We do this:
export const getBookingSchema = (allowPastTime: boolean) => {
return z.object({
location: z.string().min(1, 'Location is required'),
provider: z.string().min(1, 'Provider is required'),
isAllDay: z.boolean(),
date: z.date(),
time: z.string(),
})
// We will add the complex time validation right here in Step 3
};
Now, the schema isn’t a closed box. It accepts an argument (allowPastTime), meaning we can finally inject our React component's state into the validation rules.
Step 2: Rescuing TypeScript Inference
If you use Zod, you know the magic of z.infer. It automatically creates your TypeScript interfaces based on your schema. But because we turned our schema into a function, the standard inference breaks.
typeof getBookingSchema evaluates to a function, not a Zod object.
To fix this, we leverage a built-in TypeScript utility called ReturnType. This tells TypeScript to simulate running the function, look at what it returns, and extract the type from that.
// ✅ The correct way to infer types from a Schema Factory
type BookingFormData = z.infer<ReturnType<typeof getBookingSchema>>;
With one line of code, our React Hook Form types are perfectly restored.
Step 3: The Cross-Field “Kill Switch” (.superRefine)
Now for the heavy lifting. We need to validate if the time is in the past, but only if the config flag tells us to.
We can’t just use a simple .refine on the time field because figuring out if an appointment is in the past requires checking multiple fields at once (the date, the time, and whether it's an all-day appointment). For cross-field validation, we use Zod's .superRefine.
Here is how we build the logic, complete with our configuration “Kill Switch”:
export const getBookingSchema = (allowPastTime: boolean) => {
return z.object({
isAllDay: z.boolean(),
date: z.date(),
time: z.string(), // e.g., "14:30"
})
.superRefine((data, ctx) => {
// 1. If it's an all-day appointment, exact time doesn't matter.
if (data.isAllDay) return;
// 2. Parse the combined Date and Time (using your preferred library, like dayjs)
const [hours, minutes] = data.time.split(':').map(Number);
const selectedDateTime = dayjs(data.date).hour(hours).minute(minutes);
// 3. THE MASTER GUARD: The Configuration Kill-Switch
const timeIsInPast = selectedDateTime.isBefore(dayjs(), 'minute');
if (!allowPastTime && timeIsInPast) {
ctx.addIssue({
path: ['time'],
code: z.ZodIssueCode.custom,
message: 'Appointment time cannot be in the past.',
});
}
});
};
Take a close look at that final if statement: if (!allowPastTime && timeIsInPast).
This is JavaScript boolean short-circuiting at its finest. If a clinic administrator turns the flag ON (meaning allowPastTime is true), then !allowPastTime evaluates to false. The JavaScript engine immediately stops reading the chain. The error is skipped, the user proceeds, and the feature works exactly as the product manager requested.
Step 4: Wiring it up in React
The final step is connecting our global state to our new schema factory inside the React component.
Whether you are using a custom Context hook, Redux, or Zustand, the pattern is the same. We extract the live variable and pass it directly into the zodResolver when we initialize our form.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useGlobalConfig } from '@/hooks/useGlobalConfig'; // Your global state
export const AppointmentForm = () => {
// 1. Pull the dynamic rule from global state
const { allowPastAppointments } = useGlobalConfig();
// 2. Initialize the form, passing the dynamic rule into the factory
const { control, handleSubmit } = useForm<BookingFormData>({
resolver: zodResolver(getBookingSchema(allowPastAppointments ?? false)),
defaultValues: {
isAllDay: false,
time: '',
}
});
const onSubmit = (data: BookingFormData) => {
// Look at how clean this is! No validation logic here.
// If we reach this point, the data is 100% valid based on global rules.
api.submitAppointment(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Your form fields go here */}
</form>
);
};
Pro-tip: Notice the ?? false fallback in the resolver. If your global configuration takes a split second to load from the backend and returns undefined, the fallback ensures your form defaults to strict validation and doesn't crash.
The Architectural Takeaway
When you are working on enterprise codebases, your goal shouldn’t just be to make the ticket pass QA. Your goal is to write non-destructive features.
By using the Schema Factory pattern, we successfully bridged the gap between React’s dynamic state and Zod’s static validation without cluttering the component’s UI logic. If the business team asks for three more configuration flags next month, we don’t have to rewrite the form. We just pass a new argument into our factory.
As front-end engineers, we spend a lot of time rescuing messy codebases. Patterns like this are how we ensure we don’t accidentally become the ones making the mess.
Have you run into tricky cross-field validation issues in your React applications? Let me know your approach in the comments below, or connect with me to talk about enterprise React architecture.
Comments
Loading comments…