Go to pub-trivia.app/login, type an email that does not exist and any password, and press Sign In. You get a red toast in the corner:
Error
Invalid login credentials
Wait ten seconds for it to fade. Press Sign In again, without touching either field.
Nothing happens. No toast, no spinner, no inline message, no change to the button. The request goes out, the server rejects it again, and the interface is silent. I ran exactly that against production while writing this:
attempt1: "Error Invalid login credentials"
after wait: ""
attempt2: ""
attempt2 later:""
Ten lines, four times
Here is the whole of our login form's error handling:
const [formState, formAction] = useActionState(loginUser, initialState)
const { toast } = useToast()
useEffect(() => {
if (formState?.message) {
toast({
title: "Error",
description: formState.message,
variant: "destructive",
})
}
}, [formState?.message, toast])
The dependency is formState?.message, a string.
useActionState hands back a fresh state object on every submission, so React re-renders. But the message on the second failure is the same message as the first, character for character, because it is the same failure. The dependency array compares by value, sees no change, and does not run the effect. The only channel we have for telling the user anything decided there was nothing new to say.
It is not a race or a timing bug. It is deterministic: any two consecutive identical errors produce one toast.
That block is copy-pasted into four files, near enough verbatim:
components/LoginForm.tsx 52 lines
components/SignupForm.tsx 64 lines
components/ForgotPasswordForm.tsx 43 lines
components/ResetPasswordForm.tsx 59 lines
218 lines of authentication UI, one error-reporting idea, four copies of it, and one bug present in all four.
While I was in there
I pulled the rendered HTML of every publicly reachable auth page and counted attributes.
Six visible inputs across three pages. Zero autocomplete attributes.
<input type="email" id="email" placeholder="m@example.com" required name="email">
<input type="password" id="password" required name="password">
No autocomplete="email", no current-password, no new-password. The whole repository contains exactly one autoComplete attribute and it is an "off" on an unrelated field. Password managers mostly cope, because they have had twenty years of practice coping with forms like ours, but "mostly" is doing load-bearing work there, and new-password on the signup field is the signal that makes a manager offer to generate one.
Zero aria-invalid. Zero aria-describedby. When the toast does fire, it lands in a live region at the far end of the document. The field that caused it is not marked invalid and is not associated with the message. A screen reader user hears an announcement with no idea which of three inputs it is about, and on the second attempt hears nothing at all.
The irony is that the app already contains one input that does this properly. It is not on any of the auth pages.
One of four forms has a pending state. Signup swaps its button label while the action is in flight:
const { pending } = useFormStatus()
return <Button disabled={pending}>{pending ? 'Creating account…' : 'Sign up'}</Button>
Login, forgot password and reset password do not. The button reads "Sign In" before, during and after, which is why the silent second attempt is so complete: there is no state anywhere in the document that changes.
The label that points at nothing
Our reset page is public, so you can read this one in your own browser's inspector. Here is what it serves:
<label for="email">Password</label>
<input type="password" id="password" placeholder="Enter New Password" required name="password">
<input type="password" id="confirm_password" placeholder="Confirm Password" required name="confirm_password">
A label whose text says "Password", whose for says email, on a page with no element with the id email. Clicking it focuses nothing. A screen reader reads it as an orphan.
And then the second field has no label at all. Its only name is a placeholder, which is grey, low contrast, and disappears the moment you start typing, which is precisely when you most want to know whether you are in the first box or the second.
Three mistakes, one of them the classic. The htmlFor is a leftover from whichever form this file was copied out of.
There is also a searchParams.get('code')! feeding a hidden input, a non-null assertion on a query parameter a user can simply not have, but that one is a different post.
Why it ends up like this
None of these are hard problems. They are what happens when the error channel is a component you installed rather than a decision you made.
We mounted a toast provider in the root layout. After that, "how should this form report a failure" has an obvious answer that requires no thought, and the obvious answer is wrong for forms specifically, because a form error is about a field and a toast is about the page. The toast store is even configured with a limit of one visible toast at a time, which is sensible for notifications and is another way of saying that two errors cannot both be on screen.
The fix is not clever. Render the message underneath the field it belongs to, wire aria-describedby to it, set aria-invalid on the input, and the deduplication problem disappears along with the accessibility one, because rendered output does not care whether the string changed.
Check any of it yourself
All four pages are public and none of them need an account:
- /login, two submissions, one toast
- /signup, the one form in the set with a pending state
- /forgot-password, one input, no autocomplete
- /forgot-password/reset, the label pointing at an id that is not there
curl -s https://pub-trivia.app/login | grep -o '<input type="[^"]*"[^>]*name="[^"]*"'
curl -s https://pub-trivia.app/forgot-password/reset | grep -o '<label[^>]*>[^<]*</label>'
If you want to see the part of the product that did get the attention, the question pack and pricing pages are at pub-trivia.app and the free tier needs no card. The sign-in form, apparently, was the last thing anyone looked at, which is a reasonable description of most sign-in forms.
Top comments (0)