This article is published in English.
A Validated Birth-Date Form in Next.js With Controlled Inputs and Callbacks
Build a small Next.js client form that tracks input with useState, rejects bad birth dates, shows accessible errors and hands clean data to its parent component.
A horoscope app needs two things from a visitor before it can produce a reading: a name and a date of birth. That sounds like a five-minute form, but even this small component forces real design decisions: where it runs in a Next.js app, who owns the entered values, how invalid dates are rejected, and who decides what happens after a successful submit.
This walkthrough builds that form step by step. By the end you will have a typed, controlled form component that validates its input, reports errors accessibly and passes only clean data upward, plus a clear mental model you can reuse for any form that is more than a single field.
Decide what the component is responsible for
Before writing any JSX, it pays to list the component's jobs. This form has exactly three:
- Remember what the user has typed.
- Check that input before it is submitted.
- Hand valid data back to the parent component.
Anything outside that list, such as calling an API or rendering the reading, belongs somewhere else. Keeping the list short is what makes the rest of the design straightforward.
Why the form must be a Client Component
In the App Router, every component is a Server Component unless you opt out. Server Components render on the server and ship no interactive JavaScript, so they cannot hold state or react to events. The form therefore starts with the client directive.
"use client";
The component depends on several things that only exist in the browser:
useStatefor the current values,onChangehandlers on the inputs,- an
onSubmithandler on the form, - ongoing interaction with the user,
- validation that runs before anything is sent.
In short, the component does not merely display information; it has to respond to what the user does. That is the signal to mark it as a Client Component. A useful habit is to keep such components small and at the leaves of the tree, so the directive does not pull large parts of the page into the client bundle.
Type the data and the props
The form imports a shared Profile type along with useState.
import { Profile } from "../types";
import { useState } from "react";
Declaring the shape of the submitted data explicitly means TypeScript can check every place that produces or consumes it, rather than letting an arbitrary object travel through the app.
export type Profile ={
name: string;
dob: string;
}
Next come the component's props. There is only one: a callback the parent supplies.
type HoroscopeFormProps = {
onSubmit: (info: Profile) => void;
};
Let the parent decide what happens next
This prop is where the component boundary gets drawn. The form gathers data, but it has no business deciding what to do with it. Depending on the screen, the parent might:
- call an API,
- generate the horoscope,
- persist the profile,
- show the result,
- update some other state.
Hard-coding any of those choices inside the form would tie it to one screen. Accepting an onSubmit function instead keeps it reusable. The type says that onSubmit receives a Profile and returns nothing (void), so the form fires it and moves on. The overall flow looks like this:
User enters information
↓
HoroscopeForm collects it
↓
HoroscopeForm validates it
↓
onSubmit(user)
↓
Parent decides what happens next
Each step has a single owner, and the form's part ends the moment it calls the callback.
Store the input in React state
The form needs somewhere to keep the current values. Three pieces of state cover everything.
const [name, setName] = useState<string>("");
const [dob, setDob] = useState<string>("");
const [error, setError] = useState<string>("");
Look at the name first.
const [name, setName] = useState<string>("");
useState returns a pair. name is the current value, and setName is the function you call to replace it, which also schedules a re-render. The initial value is an empty string because nothing has been entered yet. Initialising with a string rather than undefined matters for controlled inputs: React warns if an input switches from uncontrolled to controlled when its value changes from undefined to a string.
The date of birth follows the same pattern.
const [dob, setDob] = useState<string>("");
The last piece holds the current error message.
const [error, setError] = useState<string>("");
An empty string means there is no error at the moment. Validation will write a message into this state when something is wrong, and clear it once the input passes.
Keep date validation in its own function
Validation rules tend to grow, so rather than piling them into the submit handler, the date check lives in a dedicated function. Its signature tells you the contract.
function validateDOB(dob: string): string | null {
There are exactly two outcomes. An invalid date produces a string explaining the problem; a valid one produces null.
Valid date
↓
return null
Invalid date
↓
return error message
Because the function answers one question, "is this date of birth acceptable?", it is easy to read, easy to unit test without rendering anything, and easy to reuse on a server if you later validate there too. Returning the message instead of throwing keeps the calling code simple: check the result, show it if present.
Which dates to reject
Two rules make sense for a birth date:
- No future dates. Someone cannot have been born on a day that has not happened yet.
- A sensible lower bound. Dates more than 150 years in the past are rejected as almost certainly mistyped.
Dates look simple until time of day enters the picture. An <input type="date"> yields a string in YYYY-MM-DD form, and new Date("2024-05-01") interprets that string as midnight UTC, while "today" built from new Date() carries the local hours, minutes and seconds. Depending on the user's time zone, a naive comparison can wrongly accept tomorrow or reject today. Two robust options are to normalise both sides to the start of the day before comparing, or to compare the YYYY-MM-DD strings directly, which sort correctly as text. Whatever approach you take, and whether or not an AI assistant helped you draft it, make sure you can explain why each comparison is there; date bugs hide exactly in the lines nobody understood.
Coordinate everything in the submit handler
With state and validation ready, handleSubmit ties them together. When the user submits, it must:
- stop the browser's default submission, which would reload or navigate the page,
- confirm both fields have values,
- validate the date of birth,
- show an error if anything is wrong,
- otherwise pass the data to the parent.
It begins like this.
const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault();
By default a form submission sends a request and reloads the page. Because React is handling submission here, that default has to be cancelled.
e.preventDefault();
From this point on, the component alone decides what the submission does. A note on the event type: many codebases type this parameter as React.FormEvent<HTMLFormElement>. Check which submit event types your installed @types/react version exposes and pick the one your project uses consistently.
Reject empty fields with an early return
Before checking whether the date makes sense, confirm that something was entered at all.
if (!name || !dob) {
setError("Please enter in information");
return;
}
If either field is empty, the handler records an error and returns immediately. This is the early return (or guard clause) pattern: once the input is known to be invalid, there is nothing left to do, so the function exits instead of wrapping the remaining logic in another level of if blocks. Each guard handles one failure and the "happy path" stays flat at the bottom.
Run the date check
Once a date is known to exist, it goes through the validator.
const dobError = validateDOB(dob);
The result is either a message or null, so a single check is enough.
if (dobError) {
setError(dobError);
return;
}
A message means the handler shows it and stops. null means the date passed and execution continues.
Hand clean data to the parent
Reaching this point means every check passed, so any stale error from an earlier attempt is cleared.
setError("");
Then the parent's callback receives the validated profile.
onSubmit({ name, dob });
This is the payoff of the earlier design decision. The form does not know or care what happens next; it simply announces that valid data is available, and the parent chooses what to do. The same component could feed a horoscope generator today and a profile settings screen tomorrow without changes.
Wire the logic to the markup
The form element connects submission to the handler.
<form onSubmit={handleSubmit}>
This tells React to run handleSubmit whenever the form is submitted, whether by clicking the button or pressing Enter in a field. The name field comes next.
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
How a controlled input stays in sync
This is a controlled input: React state, not the DOM, is the source of truth for its value. Whenever the user types, the change handler runs.
onChange={(e) => setName(e.target.value)}
It reads the new text from the event and stores it in state. The full loop looks like this:
User types
↓
onChange fires
↓
setName(new value)
↓
name state updates
↓
value={name}
↓
Input displays updated value
Because the input always displays whatever name holds, the value you validate is guaranteed to be the value on screen. The date field uses the same pattern.
<input
type="date"
value={dob}
onChange={(e) => setDob(e.target.value)}
/>
State tracks the chosen date, and each change calls setDob. One addition worth considering is the native max attribute set to today's date, which stops most date pickers from offering future days at all, while your validator still guards against typed input and older browsers.
Every input should also have a visible <label> associated with it. A placeholder or a nearby heading is not a substitute; the label is what screen readers announce and what makes the field clickable by its caption.
Show errors only when they exist
The error message should appear only when there is one. Conditional rendering handles that.
{error && (
<p role="alert">
{error}
</p>
)}
When error holds text, the paragraph is rendered; when it is an empty string, which is falsy, nothing appears. This && shortcut is safe here because the value is a string. With numbers it can misfire: a count of 0 would be rendered as a literal "0".
The paragraph also carries an ARIA role.
role="alert"
role="alert" tells assistive technology that this content is important and time-sensitive, so screen readers announce it as soon as it appears. It is a one-attribute change that makes validation feedback usable for people who cannot see the message pop up. For extra clarity you can also mark the offending field with aria-invalid and link it to the message with aria-describedby.
Add the submit button
The last piece is a button explicitly declared as a submit button.
<button type="submit">
Submit
</button>
Inside a form, a button with type="submit" triggers the form's onSubmit, and with it handleSubmit. Buttons inside a form default to submit anyway, but stating the type prevents surprises when someone later adds a second button meant for something else, such as clearing the fields.
The complete data flow
Seen as a whole, the component moves data in one direction:
State
↓
User input
↓
Submit
↓
Validation
↓
Parent callback
useStateholds what the user entered.- The inputs update that state on every change.
- Submitting the form runs
handleSubmit. handleSubmitvalidates the values.- Invalid input sets the error state and stops.
- Valid input goes to the parent through
onSubmit, and the parent takes it from there.
Where to go from here
This hand-rolled approach is ideal for learning and perfectly adequate for a two-field form. As forms grow to many fields, cross-field rules or server-side checks, consider a schema library so the same rules run on both client and server; sharing one Zod schema across the React frontend and Node backend is one way to do that. Client-side validation improves the experience but never replaces validation on the server, since any request can be crafted by hand.
Key takeaways
- Mark only interactive components with
"use client"and keep them small. - Give the form one job: collect, validate, hand off. Let the parent own side effects through a typed callback.
- Use controlled inputs initialised with strings so the value on screen is the value you validate.
- Put validation rules in pure functions that return a message or
null; they are easy to test and reuse. - Treat dates carefully: normalise time of day or compare
YYYY-MM-DDstrings to avoid time-zone off-by-one errors. - Use early returns to keep the submit handler flat, and
role="alert"plus proper labels to keep errors accessible. - Being able to explain why every line exists, especially lines an AI assistant suggested, is part of finishing the work.