The day an empty string broke my sign-in flow
I thought I was just checking a flaky “sign in with email” flow. Instead, I ended up staring at a request payload that said email: "" like it was perfectly normal. It wasn’t. It was the kind of bug that feels simple after you fix it and strangely slippery before you do.
The screen looked fine. Users could type. The validation told them the email was valid. The button lit up. And then the backend kept receiving an empty string. No error. No helpful crash. Just a quiet, perfect-looking request that said “email: empty.”
I kept thinking this was a network problem. It wasn’t. It was UI state.
The subtle mismatch that did the damage
The sign-in screen had two sources of truth:
a
TextEditingControllera local state variable updated via
onChanged
The form validation and button state were driven by the local state, but the submit call pulled from the controller. In most cases they stayed in sync, but in practice I was still reading an empty controller value when the user had only updated the local state.
So the UI said “looks valid,” but the request said “send nothing.”
It felt like this:
// UI state says it's valid
setState(() => emailValue = value);
// But submit reads from the controller
final email = emailController.text.trim();
Once I saw that split, the bug stopped being mysterious. It was just a wiring problem.
The change was small, the outcome wasn’t
I fixed it by normalizing the input on submit and by adding a tiny helper that chooses the “real” value, regardless of whether it came from the controller or the local state. That way the request payload is always derived from the most recent user input.
String resolvedEmail() {
final fromController = emailController.text.trim();
if (fromController.isNotEmpty) return fromController;
return emailValue.trim();
}
On the network side I also tightened the payload builder so "" never leaves the app as a credential. If it’s empty after trim, it just doesn’t get included. That single guard makes failures a lot more obvious because the backend stops receiving a misleading field.
String? normalized(dynamic value) {
if (value == null) return null;
final v = value.toString().trim();
return v.isEmpty ? null : v;
}
This wasn’t about fancy architecture. It was about respecting where the truth actually lives in the UI.
What I’m taking from this
If there are two sources of truth, there will eventually be a desync.
Payload assembly deserves as much care as UI validation.
“Empty string” bugs are quiet, and that’s exactly why they hurt.
Today was one of those days where the fix was maybe fifteen lines of code, but the time sink was in the mental model. The system was working, just not the way I assumed it was working. Flutter didn’t do anything wrong. I just had to listen to what my own code was actually saying.