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.