I built ZIP-code autofill into AI US Citizenship Test 2026, a Flutter app that helps green card holders prepare for the naturalization interview with an AI tutor, realistic N-400 mock interviews, and a full practice test engine. Several civics answers are state-specific — your representative, your senators, your governor — so asking for a ZIP code and auto-filling the city and state removed an entire screen of dropdowns from the onboarding flow.
The feature took an afternoon to build and a week to get right. Every shortcut I took in the first version — validating with int.parse\, firing the API on each keystroke, trusting the first response that arrived — produced a real bug that a real user found. What follows is the hardened pattern, so you can skip the week of bug reports.
It looks like a small feature. A text field, an API call, two filled fields. But ZIP-code autofill has a surprising number of sharp edges: leading zeros, debounce timing, cancelled requests racing each other, API failures on flaky connections, and users who paste ZIP+4 codes. This article covers the complete implementation pattern that has worked in production, including the bugs I fixed along the way.
Start with validation, not the API
The first mistake is calling the API on every keystroke. The second mistake is validating with int.parse\. Both come from treating a ZIP code as a number. It is not. It is a five-character string, and the difference matters the moment someone from Massachusetts types 02139\ — parse it as an integer and the leading zero is gone.
Validate with a regular expression and keep the value as a String\ everywhere:
\`dart
final _zipRegex = RegExp(r'^\d{5}(-\d{4})?$');
bool isValidZip(String input) => _zipRegex.hasMatch(input.trim());
`\
This accepts both the five-digit form and the extended ZIP+4 form (02139-1234\) that users paste from their clipboard. Trim whitespace first, because autofill from password managers and contact apps loves trailing spaces.
If you only need the basic five-digit code, r'^\\d{5}$'\ is stricter. I allow ZIP+4 in the field but normalize to the first five digits before the lookup:
\dart
String normalizeZip(String input) {
final cleaned = input.trim();
return cleaned.length > 5 ? cleaned.substring(0, 5) : cleaned;
}
\\
Write a widget test for the Massachusetts case specifically. It is the test that catches the int.parse\ bug, and it takes thirty seconds:
\dart
test('keeps leading zeros', () {
expect(isValidZip('02139'), isTrue);
expect(normalizeZip('02139-1234'), '02139');
});
\\
Debounce before you call anything
A lookup API charges per request or rate-limits aggressively, so fire the request only after the user stops typing. A simple Timer\-based debounce is all you need — no extra packages:
\`dart
class ZipAutofillController {
final _debounce = {};
static const _delay = Duration(milliseconds: 500);
void onZipChanged(String value, void Function(String) lookup) {
_debounce['zip']?.cancel();
final zip = normalizeZip(value);
if (!isValidZip(zip)) return; // wait for a complete code
_debounce['zip'] = Timer(_delay, () => lookup(zip));
}
void dispose() => _debounce['zip']?.cancel();
}
`\
Notice the guard: the lookup only fires when the input is a complete, valid ZIP code. Firing on partial input like 021\ wastes requests and fills your fields with flickering wrong answers. For a deeper look at the async primitives behind this pattern, see Dart Streams vs Futures in Flutter.
Cancel in-flight requests, or lose the race
Here is the bug that shipped in my first version: the user types 02139\, the request goes out, then they correct it to 02138\ before the first response arrives. The first response lands last and overwrites the correct city. Stale data wins because nothing cancelled the earlier request.
Give every lookup a generation counter and ignore responses that belong to an older one:
\`dart
class ZipLookupService {
final http.Client _client;
final Map _cache = {};
int _generation = 0;
ZipLookupService(this._client);
Future lookup(String zip) async {
if (_cache.containsKey(zip)) return _cache[zip];
final myGeneration = ++_generation;
try {
final uri = Uri.https('api.example.com', '/zip/$zip');
final response = await _client
.get(uri)
.timeout(const Duration(seconds: 8));
// A newer lookup started while we were waiting: drop this result.
if (myGeneration != _generation) return null;
if (response.statusCode != 200) return null;
final info = ZipInfo.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
_cache[zip] = info;
return info;
} on TimeoutException {
return null; // caller shows a manual-entry fallback
} on SocketException {
return null; // offline: let the user type city and state by hand
}
}
}
`\
Returning null\ on failure is a deliberate product decision, not laziness. When the lookup fails — timeout, offline, unknown ZIP — the city and state fields simply stay editable and the user types them manually. Autofill is a convenience, never a gate. The form must always be submittable without it. If you are wiring the networking layer for the first time, Flutter REST API Integration: From JSON to UI covers the http\ setup this service builds on.
Model the response defensively
ZIP lookup APIs return more than you need and occasionally less than you expect. Parse only the fields you use, and make every one of them nullable with a sensible fallback:
\`dart
class ZipInfo {
final String city;
final String state;
const ZipInfo({required this.city, required this.state});
factory ZipInfo.fromJson(Map json) {
final places = json['places'] as List?;
final place = places?.firstOrNull as Map?;
return ZipInfo(
city: (place?['place name'] as String?)?.trim() ?? '',
state: (place?['state abbreviation'] as String?)?.trim() ?? '',
);
}
bool get isComplete => city.isNotEmpty && state.isNotEmpty;
}
`\
Two details matter here. First, firstOrNull\ (from package:collection\, which ships with Flutter) avoids the StateError\ that first\ throws on an empty list — some APIs return a 200 with an empty places array for valid-but-unassigned codes. Second, the isComplete\ check: never write a half-filled result into the form. A city with no state is worse than no autofill at all, because the user assumes it is correct and stops checking.
Keep the UX honest
A few interaction details separate a polished autofill from a confusing one:
-
Show a subtle loading state on the field — a small
CircularProgressIndicator\as the suffix icon — so the user knows a lookup is running and does not retype. - Only fill empty fields. If the user already typed a city manually, do not overwrite it when the lookup returns. Autofill should never destroy user input.
- Announce failures quietly. A red error banner for a failed convenience lookup is disproportionate. Leave the fields editable; optionally show a brief hint like "Could not look up this ZIP — please enter city and state."
- Move focus forward when the lookup succeeds, so the keyboard flow continues naturally to the next field instead of stranding the user.
-
Remember the leading-zero rule in tests. Any serialization round-trip — JSON, shared preferences, Firestore — must treat the ZIP as a string, or
02139\silently becomes2139\in your database.
Cache aggressively, it is free
ZIP codes do not change. Cache every successful lookup in memory for the session, and you cut repeat requests to zero when the user edits the field back and forth. The _cache\ map in the service above does exactly that. For longer-lived caching across app restarts, persist the map to local storage keyed by ZIP — the data is tiny and effectively immutable.
In the citizenship app this mattered more than expected: users retry the onboarding flow, switch between test modes, and re-enter their ZIP on different screens. The cache turned the second and third lookups into instant, offline-safe fills.
Test the parts that actually break
The lookup service is pure Dart with an injectable http.Client\, which makes it one of the easiest things in your app to unit test — and one of the most valuable, because its bugs only show up on slow networks. Three tests cover the important behavior:
\`dart
test('caches repeated lookups', () async {
final client = MockClient((_) async => Response('{"places":[]}', 200));
final service = ZipLookupService(client);
await service.lookup('10001');
await service.lookup('10001');
verify(client.get(any)).called(1); // second call served from cache
});
test('drops stale responses', () async {
// fire two lookups; the first must not overwrite the second
});
test('returns null when offline', () async {
final client = MockClient((_) async => throw const SocketException('no route'));
final service = ZipLookupService(client);
expect(await service.lookup('10001'), isNull);
});
`\
The stale-response test deserves a real implementation, not a placeholder: use a MockClient\ that completes the first request after the second one, then assert the service returned the second result. Race conditions are only fixed if a test pins the behavior down.
Also add a widget test for the form itself: type a ZIP, pump past the debounce delay, and assert the city and state fields filled in — then clear the network mock, type another ZIP, and assert the fields stay editable instead of showing an error screen. That test documents the product decision that autofill is a convenience, never a gate.
Putting it together
The full flow, in order: validate the input as a string with a regex, debounce for half a second, skip the request on cache hit, attach a generation counter to kill stale responses, parse defensively with nullable fields, fill only empty fields, and always leave manual entry available. Each step is small; together they are the difference between a feature users trust and one they learn to work around.
I shipped the naive version first — no debounce, no cancellation, int.parse\ on the input — and every one of these fixes came from a real bug report or a real user in Massachusetts. Build the careful version from the start; it is barely more code.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Top comments (0)