惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CXSECURITY Database RSS Feed - CXSecurity.com
K
Kaspersky official blog
A
Arctic Wolf
Attack and Defense Labs
Attack and Defense Labs
L
LINUX DO - 热门话题
N
News | PayPal Newsroom
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
L
Lohrmann on Cybersecurity
PCI Perspectives
PCI Perspectives
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
The Last Watchdog
The Last Watchdog
B
Blog RSS Feed
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
W
WeLiveSecurity
Know Your Adversary
Know Your Adversary
博客园 - Franky
T
Tenable Blog
T
Tailwind CSS Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Help Net Security
Help Net Security
WordPress大学
WordPress大学
T
The Exploit Database - CXSecurity.com
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
D
Darknet – Hacking Tools, Hacker News & Cyber Security
H
Heimdal Security Blog
TaoSecurity Blog
TaoSecurity Blog
S
Security Affairs
J
Java Code Geeks
小众软件
小众软件
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Apple Machine Learning Research
Apple Machine Learning Research
NISL@THU
NISL@THU
O
OpenAI News
The Cloudflare Blog
月光博客
月光博客
Google Online Security Blog
Google Online Security Blog
V
V2EX
罗磊的独立博客
美团技术团队
博客园 - 三生石上(FineUI控件)
Security Latest
Security Latest
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
C
Cyber Attacks, Cyber Crime and Cyber Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Cyberwarzone
Cyberwarzone
L
LINUX DO - 最新话题
Hacker News - Newest:
Hacker News - Newest: "LLM"
大猫的无限游戏
大猫的无限游戏

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Choreography of validation: how to make your auth form seamless and usable
a-dev · 2026-06-27 · via DEV Community

You know, for users the best login is no login at all. But we still need one, and the most popular is the email/password form. And honestly, most of them are a mess. They nag you too early or too late: red errors when you've already typed a perfectly good email - or before you've typed a single character. I've seen it a lot.

Here I want to share one way to make this better - a very opinionated one. I don't want to drift into the "philosophy of UI", so it's a concrete example on a concrete stack: React, TanStack Form, Zod, and e.g. Better Auth on the backend.

Reward early, punish late

This is the main principle of good validation UX: find the right moment to tell users they messed up. And the right moment to tell them they're back on track.

The most common mistake is being too eager. The form screams "Invalid email!" while you're still typing. That's how the browser's native <input type="email"> behaves, and how most libs do it too with onChange.

I almost always prefer onBlur. It's a fair moment to say: hey, this field has something invalid, please fix it. But the interesting part comes next - what happens while the user is fixing it?

Usually the error just sits there while they retype, and clears only on the next blur, or after they submit. For some libraries that's the default.

Another flavor: some forms show the error only when you click the button. Too late. Especially when the error has nothing to do with the backend and could be caught on the frontend. And it gets worse: you see the error, fix your input, and it stays until you click again. Frustrating, right?
Ok, enough complaining, let's fix it.

Demo: https://a-dev.github.io/probes/validation

Three independent timelines

What helped me was a mental model: "the error" isn't one thing. It has three separate lives: when it's born, when it's shown to the user, and when it dies. We control each one independently.

The birth of an error

The naive approach shows a field's error whenever the value is invalid. Too eager, like we said. Validating only on submit is too lazy. onBlur is a good compromise, but it has its own catch: the error is born the moment you leave the field, even if you typed nothing. Click in, click out, and you get an error for a field you never filled.

Back to our principle. "Punish late" - don't create an error until the user has actually typed something. "Reward early" - once it's born, kill it the moment they start typing again. It feels right: they're actively fixing things, so let's reward that.

TanStack Form 1.x lets us express exactly this with a single onDynamic validator and a custom validationLogic method. Thanks to the team for it - powerful and flexible.

// reward-early-validation.ts
// universal dynamic validation logic
import type { ValidationLogicFn } from "@tanstack/react-form";

export const rewardEarlyPunishLate: ValidationLogicFn = ({
  form,
  validators,
  event,
  runValidation,
}) => {
  // If the form is async, we need to use the async version of the dynamic validator
  const dynamicValidator = event.async ? validators?.onDynamicAsync : validators?.onDynamic;

  // Has the field that triggered this event already surfaced an error? Only then do we re-judge on every keystroke (so the fix is rewarded instantly)
  const fieldHasError =
    !!event.fieldName && (form.getFieldMeta(event.fieldName)?.errors.length ?? 0) > 0;

  const shouldValidate =
    event.type === "submit" || event.type === "blur" || (event.type === "change" && fieldHasError);

  // NOTE: runValidation returns the validator array the form actually runs, and the caller consumes that return value — so we must `return` it, even though the type says `=> void`
  return runValidation({
    validators:
      shouldValidate && dynamicValidator ? [{ fn: dynamicValidator, cause: "dynamic" }] : [],
    form,
  });
};

And the form:

// login-form.tsx
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { rewardEarlyPunishLate } from "./reward-early-validation";

function buildLoginSchema() {
  return z.object({
    email: z.email("Enter a valid email address"),
    password: z.string().min(1, "Enter your password"),
  });
}

const form = useForm({
  defaultValues: { email: "", password: "" },
  validationLogic: rewardEarlyPunishLate,
  validators: {
    onDynamic: ({ value }) => toFieldErrors(value), // the single source of error truth
  },
  // ...
});

The validator delegates the checking to a helper that adapts Zod's output into the TanStack Form shape:

function toFieldErrors(values: LoginValues) {
  const result = buildLoginSchema().safeParse(values);
  if (result.success) return undefined;

  // Zod 4's treeifyError gives us a nested tree; we pull the first message per field
  const tree = z.treeifyError(result.error);

  const fields = Object.fromEntries(
    Object.entries(tree.properties ?? {}).flatMap(([fieldName, fieldError]) => {
      const message = fieldError.errors[0];
      return message ? [[fieldName, message]] : [];
    }),
  );

  return Object.keys(fields).length > 0 ? { fields } : undefined;
}

Why not two validators: the onBlur (to catch a field you focus and leave without typing, no change event fires there) and the onChange (to clear the error as you fix it)? It works, but with a nasty side effect: TanStack stores errors per trigger (errorMap.onBlur, errorMap.onChange, …) and flattens them into one errors array. Two validators returning the same message produce duplicates, and, worse, an onChange returning undefined can't clear an onBlur error, so field state and the rendered UI disagree.

Routing both blur and change-while-errored through one validator avoids all that. One trigger, one errorMap entry, no duplicates. And when the value becomes valid, the error clears immediately instead of just hiding.

Note: typing in field B never starts live-validating it just because field A is invalid, the fieldHasError check only applies to the field that triggered the event.

Show the error

The errors now live in field.state.meta.errors. Time to show them with a small hook.

// use-field-display-errors.ts
export function useFieldDisplayErrors(field: AnyFieldApi) {
  const [editing, setEditing] = useState(false);

  // Show errors only once the user has left the field (isTouched) and is not currently re-editing it. Start typing again → hide until the next blur re-judges

  const errors =
    field.state.meta.isTouched && !editing ? getErrorMessages(field.state.meta.errors) : [];

  return {
    errors,
    invalid: errors.length > 0,
    markEditing: () => setEditing(true), // call from input onChange
    markSettled: () => setEditing(false), // call from input onBlur
  };
}

The field component wires the two callbacks into the input's native events:

// input-field.tsx
const { errors, invalid, markEditing, markSettled } = useFieldDisplayErrors(field);

<Input
  value={field.state.value}
  onBlur={() => {
    markSettled();
    field.handleBlur();
  }}
  onChange={(e) => {
    markEditing();
    field.handleChange(e.target.value);
  }}
  invalid={invalid}
/>;

{
  invalid &&
    errors.map((msg) => (
      <Field.Error key={msg} match>
        {msg}
      </Field.Error>
    ));
}

isTouched is doing real work here. Because onDynamic is a form-level validator, a blur runs validation for all fields, not just the blurred one. The isTouched check keeps the still-unvisited fields quiet until the user actually lands on them.

Yes, there's a trade-off: click into a field, click out empty, and you'll see an error. But hiding errors on empty fields is worse - a required field should tell you it's required.

The !editing half is the "calm while you fix it" touch: the message vanishes the instant you start typing and comes back (if still wrong) on the next blur.

getErrorMessages just turns each entry (a string or { message }) into display text. No de-duplication - the single-trigger design can't produce duplicates in the first place. (That would bite you with multiple validators emitting the same message, but we don't have those here).

The death of an error

Some things you can only check on the server. In our example the error comes from Better Auth. It's a verdict on one specific (email, password) pair. So the moment the user edits either field, it's stale, and we remove it.

// use-login.ts
const [formError, setFormError] = useState<LoginError | null>(null);

const form = useForm({
  // ...
  listeners: {
    // The banner reflects a verdict on a credential combination. The moment the user edits either field, that verdict is stale — so clear it
    onChange: () => setFormError(null),
  },
  onSubmit: async ({ value }) => {
    setFormError(null);
    const email = value.email.trim();
    const { error } = await authClient.signIn.email({
      email,
      password: value.password,
      rememberMe: true,
    });

    if (error) {
      // I prefer not to show raw error messages from Better Auth or other auth libraries, because they can be too technical or expose too much information. Instead, we classify the error into a user-friendly message by the error code or status. For example, 400/401 errors should stay deliberately ambiguous so we never reveal which field was wrong
      setFormError(classifyLoginError(error, email));
      return;
    }
    await onNavigate(resolveSafeRedirect(redirectTarget));
  },
});

Same refrain as everywhere: the instant they type, the verdict is gone. Reward the fix.

Finale

Let's weigh the trade-offs.

Starting with cons:
The first one is about implementation. validationLogic doesn't feel fully baked yet - it still has todos about types. It'll surely get polished, which also means the API can still change.

There's a small, intentional gap between state and screen. The !editing check hides the message while the user types, even though the error is still sitting in field.state.meta.errors. Deliberate, calmer UX, but anything reading error state directly during that window sees something the user doesn't.

A server error can vanish before it's even read, if a hurried user starts typing before the banner renders. Trade-off again: rewarding the fix is good, but a message that flashes and disappears is just confusing. Fix: add a short delay before clearing it.

Every run calls buildLoginSchema().safeParse(...), rebuilding the schema each time. On purpose, but still. Trivial for a login form; for something bigger, you might want to cache it.

Only the first error per field shows up. It's a UX choice. Want all of them? Make toFieldErrors return every message.

And now pros:

It feels good. Really good. No nagging, no frustration, no confusion. The user is rewarded for fixing their input, and errors show up exactly when they're useful and vanish the moment they're not.

Clear separation of concerns. "When does an error exist?" (validationLogic), "should we show it now?" (display hook), "is the server verdict still true?" (listener). Three small, separately testable decisions. I like that.

A single onDynamic trigger means no duplicate messages and no drift between field state and screen. And it's reusable, the same validationLogic and display hook can drive any form.

Overall, I picked a very specific stack and solved it there, and as you can see, the cons are mostly about implementation, the pros mostly about UX. The idea ports to other stacks and libraries, though, and I hope it helps you make your auth forms (and not only auth forms) better.