Người dùng gõ “rea”, sau đó gõ tiếp thành “react”. Request cho chuỗi ngắn lại trả về sau cùng và ghi đè kết quả mới. UI vẫn hoạt động, không có exception, nhưng danh sách không còn ứng với từ khóa đang hiển thị.
Debounce giúp giảm số lần bắt đầu tìm kiếm khi người dùng đang gõ. Nó không bảo đảm thứ tự hoàn tất của những request đã gửi. Bài viết dùng API lõi Dart và Flutter, đối chiếu tài liệu ngày 22/10/2026, không yêu cầu một package debounce riêng.
Ba việc cần xử lý độc lập
- Hủy Timer cũ khi người dùng tiếp tục thay đổi từ khóa.
- Bỏ kết quả thuộc một truy vấn đã bị thay thế.
- Không cập nhật State sau khi widget đã dispose.
Chỉ làm việc đầu tiên chưa đủ. Một Timer đã chạy callback có thể đã tạo request; gọi cancel() trên Timer lúc đó không hủy công việc mạng. Tương tự, mounted chỉ cho biết State còn trong cây widget, không cho biết response có đúng với từ khóa hiện tại hay không.
Tách phần tìm kiếm khỏi giao diện
Truyền hàm tìm kiếm vào widget để ví dụ không bị gắn với HTTP client cụ thể. Hàm nhận từ khóa và trả một Future<List<String>>; trong ứng dụng thật, lớp repository có thể chuyển dữ liệu server thành model trước khi trả cho UI.
import 'dart:async';
import 'package:flutter/material.dart';
class SearchPanel extends StatefulWidget {
const SearchPanel({super.key, required this.search});
final Future<List<String>> Function(String query) search;
@override
State<SearchPanel> createState() => _SearchPanelState();
}
class _SearchPanelState extends State<SearchPanel> {
Timer? _timer;
int _version = 0;
bool _loading = false;
String? _error;
List<String> _items = [];
void _onChanged(String value) {
final version = ++_version;
final query = value.trim();
_timer?.cancel();
setState(() {
_items = [];
_error = null;
_loading = query.isNotEmpty;
});
if (query.isEmpty) return;
_timer = Timer(const Duration(milliseconds: 300), () {
_runSearch(query, version);
});
}
Future<void> _runSearch(String query, int version) async {
try {
final items = await widget.search(query);
if (!mounted || version != _version) return;
setState(() {
_items = items;
_loading = false;
});
} catch (_) {
if (!mounted || version != _version) return;
setState(() {
_error = 'Chưa tải được kết quả. Hãy thử lại.';
_loading = false;
});
}
}
@override
void dispose() {
_timer?.cancel();
_version++;
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(children: [
TextField(
onChanged: _onChanged,
decoration: const InputDecoration(labelText: 'Tìm kiếm'),
),
if (_loading) const LinearProgressIndicator(),
if (_error != null) Text(_error!),
Expanded(
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (_, index) => ListTile(title: Text(_items[index])),
),
),
]);
}
}
Đặt widget trong vùng có chiều cao hữu hạn, chẳng hạn Scaffold.body, vì phần danh sách dùng Expanded. Khoảng debounce 300 ms chỉ là lựa chọn minh họa; hãy điều chỉnh theo cách nhập và yêu cầu trải nghiệm của sản phẩm, không xem đó là một con số tối ưu chung.
Vì sao tăng version ngay khi đổi chữ?
Giả sử request A đang chạy. Người dùng vừa gõ từ khóa B nhưng Timer của B chưa hết thời gian chờ. Nếu chỉ đổi version khi bắt đầu request B, response A vẫn có thể lọt qua trong khoảng chờ đó. Tăng version ngay ở onChanged làm A hết hiệu lực ngay khi ý định tìm kiếm thay đổi.
Nhánh lỗi cũng phải kiểm tra version. Nếu một request cũ thất bại sau khi request mới thành công, nó không được phép thay danh sách mới bằng thông báo lỗi. Vì lý do tương tự, một khối finally đặt _loading = false không có điều kiện có thể tắt spinner của truy vấn mới.
Khi người dùng xóa hết từ khóa, version vẫn tăng và Timer bị hủy. Kết quả đang bay về vì thế không tự làm danh sách xuất hiện lại. Bạn có thể thay UI rỗng bằng hướng dẫn “Nhập từ khóa” hoặc lịch sử tìm kiếm tùy sản phẩm; đó là quyết định trình bày riêng.
Hủy kết quả khác với hủy request
Mẫu này bỏ qua response không còn liên quan, nhưng không dừng lưu lượng mạng đã bắt đầu. Nếu HTTP client hỗ trợ hủy hoặc repository quản lý tác vụ đang chạy, có thể thêm cơ chế hủy ở lớp đó. Vẫn nên giữ version check, vì yêu cầu hủy và callback hoàn tất có thể ở gần nhau.
Nếu đổi repository hoặc bộ lọc tìm kiếm khi widget vẫn được giữ lại, cần vô hiệu hóa truy vấn thuộc cấu hình trước. Có thể xử lý ở didUpdateWidget hoặc thiết kế parent tạo key mới phù hợp. Không nên ngầm giả định mọi thay đổi điều kiện tìm kiếm đều đi qua TextField.
Kiểm tra với thứ tự trả về có chủ đích
Trong widget test, thay repository bằng các Completer<List<String>> để kiểm soát thời điểm hoàn tất. Gõ A rồi B, hoàn tất B trước A và quan sát chỉ B được giữ. Thử tiếp lỗi của A, xóa từ khóa khi đang chờ, rồi đóng màn hình trước khi Future hoàn tất.
Đây là kế hoạch kiểm thử cần chạy trên dự án, không phải khẳng định rằng snippet đã được chạy trên thiết bị Android/iOS. Bài FutureBuilder và vòng đời Future bổ sung góc nhìn về nơi tạo tác vụ; bài hiện tại tập trung vào tính hợp lệ của response khi có nhiều truy vấn.
Kết luận
Debounce kiểm soát thời điểm bắt đầu, version kiểm soát kết quả còn hợp lệ và mounted bảo vệ vòng đời widget. Xử lý rõ ba ranh giới này giúp tránh một loại lỗi tìm kiếm khó thấy khi chỉ thử với mạng nhanh.


