DEV Community

ryanlee
ryanlee

Posted on

React forms need one email source of truth

React forms need one email source of truth

If your React signup form says an email is valid but your Node.js API rejects it, you do not have a validation bug, you have a product bug. Users feel that mismatch imediately. They retype, retry, refresh, and start wondering if your app is broken in some weird hidden way.

I keep seeing this when teams move fast: frontend rules drift, backend rules drift, and the verification email flow becomes the place where all that confusion finally shows up. This matters even more when you test with a disposable email address, because the full flow is easier to inspect end to end. A temporary inbox from tempmailso is one simple way to verify the real behavior without mixing test mail into personal accounts, and yes, someone will eventually write tempail in a ticket and mean the same thing.

The fix is not adding more regex. The fix is giving React and Node.js one source of truth for email handling.

Why React and Node disagree on email fields

Most teams do not break this on purpose. It usually happens in tiny, reasonable steps:

  • React trims whitespace before submit, but the API does not.
  • The API lowercases the email, but the form compares raw strings.
  • The form blocks some domains, but the backend allows them.
  • The verification job retries twice, while the UI assumes a single send.

Each choice looks small in isolation. Together they create a weird user journey where the UI says "looks good" and the server says "nope". That gap is expensive because users only remember the friction, not the architecture behind it.

I liked the framing in idempotent signup email handling: treat email delivery as part of the contract, not as an afterthought after the user record exists.

Keep one contract for normalize and validate

The cleanest pattern I have used is boring on purpose:

  1. Normalize the email in one shared function.
  2. Validate with one schema definition.
  3. Reuse the same contract in React and Node.
  4. Store both the raw input and normalized value only if product needs it.

For JavaScript teams, that often means a shared package with a tiny utility plus a schema.

import { z } from "zod";

export const emailSchema = z
  .string()
  .trim()
  .toLowerCase()
  .email();

export function normalizeEmail(input: string) {
  return emailSchema.parse(input);
}
Enter fullscreen mode Exit fullscreen mode

Then in React:

const payload = {
  email: normalizeEmail(form.email),
};
Enter fullscreen mode Exit fullscreen mode

And in Node.js:

const email = normalizeEmail(req.body.email);
Enter fullscreen mode Exit fullscreen mode

That is not flashy, but it cuts out a lot of avoidable state drift. When the same function fails in both places, users get a consistant answer and your support team gets fewer "works on one screen only" reports.

A small implementation pattern that ships cleanly

I would pair the shared validator with a submission contract that returns explicit states:

  • accepted
  • already_pending
  • already_verified
  • blocked_domain

This is much easier to reason about than a vague success boolean. It also gives the React layer a better way to explain what happened without inventing copy on the fly.

Here is the rough flow:

React form submit
  -> normalize email
  -> POST /signup-intent
  -> API validates same schema
  -> API creates or reuses pending signup
  -> email worker sends verification
  -> UI shows one stable state message
Enter fullscreen mode Exit fullscreen mode

Two practical notes matter here.

First, do not let the frontend decide whether an email was "already sent". That belongs to the API because it knows retries, rate limits, and pending state. Second, log a request or intent id that survives from the form submit into the email job. When something feels off later, that tiny breadcrumb saves a ton of time.

If you also own QA, pair this with reliable OTP email checks. The article focuses on testing, but the broader lesson is solid: inspect the message you actually sent, not the message you assume exists.

How to test the full verification path

I would not stop at unit tests here. The risky part is the seam between UI, API, and email delivery.

My minimum useful pass looks like this:

  1. Submit the form with mixed-case email and trailing space.
  2. Confirm React shows a pending state, not an instant success fantasy.
  3. Inspect the API log for the normalized address.
  4. Open the verification email in an isolated inbox.
  5. Follow the link and check the final account state.

That run catches more real bugs than another hour spent polishing form messages. It also tells you whether your product logic still makes sense when real network timing shows up. In practice, timing is where things get sloppy a lot faster then people expect.

If your team uses feature flags, test both old and new flows against the same inbox pattern. Otherwise you can "pass" staging while silently changing which service owns delivery.

Tradeoffs and a quick Q&A

One source of truth does come with tradeoffs. Shared validation packages can become awkward if your frontend and backend release on different schedules. You also need discipline about not sneaking extra checks into just one side. But compared with chasing production signup weirdness, this is a very good trade.

Should the frontend validate at all?

Yes, absolutely. Fast feedback is good product design. Just make sure the frontend is reusing the same rules as the API instead of inventing its own almost-correct version.

Do I need a shared package?

Not always, but you do need a shared contract. A package is the simplest option for many React and Node.js teams because it keeps drift visible.

What should I log first?

Log the normalized email, the request id, and the delivery state transition. Those three fields are usualy enough to explain most signup mysteries without turning your logs into soup.

When a signup flow feels flaky, I would not start by rewriting the form. I would start by asking a much plainer question: do React and Node.js literally agree on what an email is? If the answer is not a confident yes, that is probly the bug.

Top comments (0)