Search
Implementing efficient search functionality — from local filtering to real-time API search with debouncing.
Search allows users to find specific items within a dataset by typing a query. It's essential for apps with large amounts of data, such as:
- E-commerce apps (search for products)
- Social media apps (search for users or posts)
- Messaging apps (search for messages or contacts)
- Content apps (search for articles or videos)
🎯 Two Approaches
- Local Search – Search within data already loaded in the app
- API Search – Send the search query to a server and get results
There are three main search strategies used in Flutter apps:
Local search filters data that's already loaded in memory. It's fast and works offline.
import 'package:flutter/material.dart';
class LocalSearchScreen extends StatefulWidget {
@override
State<LocalSearchScreen> createState() => _LocalSearchScreenState();
}
class _LocalSearchScreenState extends State<LocalSearchScreen> {
final List<String> _allItems = [
'Apple',
'Banana',
'Cherry',
'Date',
'Elderberry',
'Fig',
'Grape',
'Honeydew',
'Kiwi',
'Lemon',
'Mango',
'Nectarine',
'Orange',
'Papaya',
'Quince',
];
List<String> _filteredItems = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filteredItems = _allItems;
_searchController.addListener(_filterItems);
}
@override
void dispose() {
_searchController.removeListener(_filterItems);
_searchController.dispose();
super.dispose();
}
void _filterItems() {
final query = _searchController.text.toLowerCase().trim();
setState(() {
if (query.isEmpty) {
_filteredItems = _allItems;
} else {
_filteredItems = _allItems
.where((item) => item.toLowerCase().contains(query))
.toList();
}
});
}
void _clearSearch() {
_searchController.clear();
setState(() {
_filteredItems = _allItems;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Local Search'), centerTitle: true),
body: Column(
children: [
// Search Bar
Padding(
padding: EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search fruits...',
prefixIcon: Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
// Results count
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_filteredItems.length} items',
style: TextStyle(color: Colors.grey.shade600),
),
if (_searchController.text.isNotEmpty)
TextButton(onPressed: _clearSearch, child: Text('Clear')),
],
),
),
Expanded(
child: _filteredItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey.shade400,
),
SizedBox(height: 16),
Text(
'No results found',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
),
),
SizedBox(height: 8),
Text(
'Try a different search term',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
)
: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
return ListTile(
leading: CircleAvatar(child: Text(item[0])),
title: Text(item),
trailing: Icon(Icons.arrow_forward_ios, size: 16),
);
},
),
),
],
),
);
}
}
✅ When to Use Local Search
- Small to medium datasets (under 10,000 items)
- Data is already loaded in memory
- Need offline search capability
- Simple filtering by text
API search sends the search query to a server and displays the results. This is necessary for large datasets.
import 'dart:convert';
import 'package:http/http.dart' as http;
class SearchService {
final http.Client client;
SearchService({http.Client? client}) : client = client ?? http.Client();
Future<List<dynamic>> search(String query) async {
if (query.isEmpty) return [];
final response = await client.get(
Uri.parse(
'https://jsonplaceholder.typicode.com/posts?q=${Uri.encodeComponent(query)}',
),
headers: {'Accept': 'application/json'},
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as List<dynamic>;
} else {
throw Exception('Search failed: ${response.statusCode}');
}
}
}
import 'package:flutter/material.dart';
import 'api_search_service.dart';
class ApiSearchScreen extends StatefulWidget {
@override
State<ApiSearchScreen> createState() => _ApiSearchScreenState();
}
class _ApiSearchScreenState extends State<ApiSearchScreen> {
final SearchService _searchService = SearchService();
final TextEditingController _searchController = TextEditingController();
List<dynamic> _results = [];
bool _isLoading = false;
bool _hasSearched = false;
bool _hasError = false;
String? _errorMessage;
Future<void> _performSearch() async {
final query = _searchController.text.trim();
if (query.isEmpty) {
setState(() {
_results = [];
_hasSearched = false;
});
return;
}
setState(() {
_isLoading = true;
_hasError = false;
_errorMessage = null;
_hasSearched = true;
});
try {
final results = await _searchService.search(query);
setState(() {
_results = results;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_hasError = true;
_errorMessage = e.toString();
});
}
}
void _clearSearch() {
_searchController.clear();
setState(() {
_results = [];
_hasSearched = false;
_hasError = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('API Search'), centerTitle: true),
body: Column(
children: [
// Search Bar
Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search posts...',
prefixIcon: Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.grey.shade50,
),
onSubmitted: (_) => _performSearch(),
),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _performSearch,
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(Icons.search),
),
],
),
),
// Results
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Searching...'),
],
),
);
}
if (_hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(_errorMessage ?? 'Search failed'),
SizedBox(height: 16),
ElevatedButton(onPressed: _performSearch, child: Text('Retry')),
],
),
);
}
if (!_hasSearched) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.grey.shade400),
SizedBox(height: 16),
Text(
'Search for posts',
style: TextStyle(fontSize: 18, color: Colors.grey.shade600),
),
Text(
'Enter a keyword to start searching',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
);
}
if (_results.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
SizedBox(height: 16),
Text(
'No results found',
style: TextStyle(fontSize: 18, color: Colors.grey.shade600),
),
Text(
'Try a different search term',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
);
}
return ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(item['title'] ?? 'No title'),
subtitle: Text(
item['body'] ?? 'No body',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: CircleAvatar(child: Text((index + 1).toString())),
),
);
},
);
}
}
Debouncing prevents making an API call on every keystroke. Instead, it waits for the user to stop typing (e.g., 500ms) before performing the search.
⚡ Why Debounce?
- Prevents unnecessary API calls
- Reduces server load
- Improves performance and battery life
- Better user experience (no constant loading)
import 'dart:async';
import 'package:flutter/material.dart';
import 'api_search_service.dart';
class DebouncedSearchScreen extends StatefulWidget {
@override
State<DebouncedSearchScreen> createState() => _DebouncedSearchScreenState();
}
class _DebouncedSearchScreenState extends State<DebouncedSearchScreen> {
final SearchService _searchService = SearchService();
final TextEditingController _searchController = TextEditingController();
Timer? _debounceTimer;
List<dynamic> _results = [];
bool _isLoading = false;
bool _hasSearched = false;
bool _hasError = false;
String? _errorMessage;
static const Duration debounceDelay = Duration(milliseconds: 500);
@override
void initState() {
super.initState();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.removeListener(_onSearchChanged);
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
// Cancel the previous timer
_debounceTimer?.cancel();
// Start a new timer
_debounceTimer = Timer(debounceDelay, () {
_performSearch();
});
}
Future<void> _performSearch() async {
final query = _searchController.text.trim();
// Don't search for empty queries
if (query.isEmpty) {
setState(() {
_results = [];
_hasSearched = false;
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_hasError = false;
_errorMessage = null;
_hasSearched = true;
});
try {
final results = await _searchService.search(query);
setState(() {
_results = results;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_hasError = true;
_errorMessage = e.toString();
});
}
}
void _clearSearch() {
_debounceTimer?.cancel();
_searchController.clear();
setState(() {
_results = [];
_hasSearched = false;
_hasError = false;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Debounced Search'), centerTitle: true),
body: Column(
children: [
// Search Bar
Padding(
padding: EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Type to search...',
prefixIcon: Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear),
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
// Status bar
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_isLoading
? 'Searching...'
: _hasSearched
? '${_results.length} results'
: 'Type to search',
style: TextStyle(color: Colors.grey.shade600),
),
if (_searchController.text.isNotEmpty)
Text(
'⌛ ${debounceDelay.inMilliseconds}ms delay',
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
),
],
),
),
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
// Show loading indicator while debouncing
if (_isLoading) {
return Center(child: CircularProgressIndicator());
}
if (_hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: Colors.red),
SizedBox(height: 16),
Text(_errorMessage ?? 'Search failed'),
SizedBox(height: 16),
ElevatedButton(onPressed: _performSearch, child: Text('Retry')),
],
),
);
}
if (!_hasSearched) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.grey.shade400),
SizedBox(height: 16),
Text(
'Start typing to search',
style: TextStyle(fontSize: 18, color: Colors.grey.shade600),
),
Text(
'Results will appear after you stop typing',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
);
}
if (_results.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
SizedBox(height: 16),
Text(
'No results found',
style: TextStyle(fontSize: 18, color: Colors.grey.shade600),
),
Text(
'Try a different search term',
style: TextStyle(color: Colors.grey.shade400),
),
],
),
);
}
return ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(item['title'] ?? 'No title'),
subtitle: Text(item['body'] ?? 'No body'),
leading: CircleAvatar(child: Text((index + 1).toString())),
),
);
},
);
}
}
TextField
with a search icon and clear button.
Timer
to delay the search until the user stops typing.
✅ Do's
- Always add a clear button to the search field
- Show loading indicators while searching
- Display search results in a clear, scannable format
- Show a "no results" message with helpful suggestions
- Debounce API searches (300-500ms delay)
- Handle errors gracefully with retry options
- Highlight matching text in results (optional)
❌ Don'ts
- Don't search on every keystroke without debouncing
- Don't show raw JSON or error messages
- Don't forget to handle empty searches
- Don't block the UI while searching
- Don't ignore the search input when it's empty
Without debouncing, every keystroke triggers an API call, causing performance issues and server load.
Use a
Timer
with a 300-500ms delay before performing the search.
If the user clears the search field, the app should return to the default state (show all items).
When the search query is empty, reset the results to show all items or show a placeholder.
If a search takes a long time and the user types another query, the old request should be cancelled.
Use a
CancelToken
or check if the search term has changed before processing results.
🎯 Key Takeaway
Search is a critical feature for apps with large datasets. Use local filtering for small datasets and API search for large ones. Always debounce API searches to prevent unnecessary requests. Handle all states – loading, error, empty, and results. A well-designed search enhances the user experience significantly.