Almost every real app needs a search bar. A contact list, a product catalogue, a recipe finder — they all share the same pattern: the user types a query, and the list below updates in real time to show only matching results.
The good news is that a Flutter search bar does not need a package. Flutter’s built-in TextField, setState(), and ListView.builder are all you need for a fully working real-time search. Once you understand the two-list pattern at the core of it — an original list you never touch, and a filtered list you display — most search bar bugs become obvious and easy to fix.
This guide builds a complete search bar step by step, explains every design decision, shows how to extend it to API data and objects, covers the built-in Material 3 SearchBar widget, every common beginner bug with fixes, and finishes with interview Q&As on the topic.
- Comfortable with StatefulWidget and setState() — the search bar updates rely on it entirely
- Know how TextField and TextEditingController work
- Familiar with ListView.builder — our complete guide covers it from scratch
1. The Two-List Pattern — The Core Idea
Before writing any code, understand the one rule that prevents almost every search bar bug: never filter your original list in place. Always keep two separate lists:
| List | Name convention | How it changes | Role |
|---|---|---|---|
| Source list | _allItems | Never — set once and left alone | The complete dataset. Never passed to ListView directly. |
| Display list | _filteredItems | Every keystroke — rebuilt by filtering _allItems | What the ListView actually shows. Replaced on every query change. |
// The two-list pattern — mental model
User types "ap"
↓
onChanged("ap") fires
↓
_allItems.where((item) => item contains "ap") → ["Apple", "Grapes"]
↓
setState(() => _filteredItems = ["Apple", "Grapes"])
↓
ListView.builder rebuilds with 2 items
User clears the field
↓
onChanged("") fires
↓
_filteredItems = List.from(_allItems) ← full list restored from original
↓
ListView.builder rebuilds with all 10 items
The most common search bar bug is filtering
_allItems directly. After one search, items are gone permanently — clearing the field cannot restore them because the originals no longer exist. Two lists sidestep this completely: the source list is your safety net, the display list is whatever the current query produces.
2. The Minimal Working Search Bar
Here is the smallest possible working search bar — no extras, just the pattern. Read through the comments before looking at the full version in the next section:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: MinimalSearchPage()));
}
class MinimalSearchPage extends StatefulWidget {
const MinimalSearchPage({super.key});
@override
State<MinimalSearchPage> createState() => _MinimalSearchPageState();
}
class _MinimalSearchPageState extends State<MinimalSearchPage> {
// 1. The source list — never modified after init
final List<String> _allItems = [
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
'Fig', 'Grapes', 'Honeydew', 'Kiwi', 'Lemon',
];
// 2. The display list — starts as a copy of _allItems, rebuilt on each query
List<String> _filteredItems = [];
@override
void initState() {
super.initState();
// 3. Initialise the display list so everything shows on first load
_filteredItems = List.from(_allItems);
}
// 4. Called on every keystroke via onChanged
void _filterItems(String query) {
setState(() {
if (query.trim().isEmpty) {
_filteredItems = List.from(_allItems); // empty query → show all
} else {
_filteredItems = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Minimal Search')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
onChanged: _filterItems, // 5. Fires on every keystroke
decoration: const InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
// 6. Expanded is REQUIRED — ListView inside Column needs bounded height
Expanded(
child: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) => ListTile(
title: Text(_filteredItems[index]),
),
),
),
],
),
);
}
}
A
ListView inside a Column without Expanded throws a layout error: “RenderFlex children have non-zero flex but incoming height constraints are unbounded.” This is the #1 layout error in beginner search bar code. Expanded tells the ListView to fill all remaining vertical space — add it every single time.
3. Full Search Bar with Clear Button and Empty State
The minimal version works but is missing two things every real search bar needs: a clear button to reset the field, and an empty state message when nothing matches. Here is the complete version with both, plus a TextEditingController to drive the clear button:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: SearchDemoPage()));
}
class SearchDemoPage extends StatefulWidget {
const SearchDemoPage({super.key});
@override
State<SearchDemoPage> createState() => _SearchDemoPageState();
}
class _SearchDemoPageState extends State<SearchDemoPage> {
// Controller needed so the clear button can programmatically empty the field
final TextEditingController _searchController = TextEditingController();
final List<String> _allItems = [
'Apple', 'Avocado', 'Banana', 'Blueberry', 'Cherry',
'Coconut', 'Date', 'Elderberry', 'Fig', 'Grapes',
'Honeydew', 'Jackfruit', 'Kiwi', 'Lemon', 'Lychee',
'Mango', 'Melon', 'Orange', 'Papaya', 'Peach',
'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Strawberry',
];
List<String> _filteredItems = [];
@override
void initState() {
super.initState();
_filteredItems = List.from(_allItems);
}
void _filterItems(String query) {
setState(() {
if (query.trim().isEmpty) {
_filteredItems = List.from(_allItems);
} else {
_filteredItems = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
}
});
}
// Clear button handler: empties the controller AND resets the filtered list
void _clearSearch() {
_searchController.clear(); // clears the visible text field
_filterItems(''); // resets the filtered list to show everything
}
@override
void dispose() {
_searchController.dispose(); // always dispose controllers
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('🔍 Fruit Finder'), centerTitle: true),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// ── Search field ──────────────────────────────────────────────────
TextField(
controller: _searchController,
onChanged: _filterItems,
decoration: InputDecoration(
hintText: 'Search fruits...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
tooltip: 'Clear search',
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
),
const SizedBox(height: 8),
// ── Result count ──────────────────────────────────────────────────
Align(
alignment: Alignment.centerLeft,
child: Text(
'${_filteredItems.length} result${_filteredItems.length == 1 ? '' : 's'}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
),
const SizedBox(height: 8),
// ── Results list ──────────────────────────────────────────────────
Expanded(
child: _filteredItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'No results for "${_searchController.text}"',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
const SizedBox(height: 8),
TextButton(
onPressed: _clearSearch,
child: const Text('Clear search'),
),
],
),
)
: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.label_outline),
title: Text(_filteredItems[index]),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Selected: ${_filteredItems[index]}'),
duration: const Duration(seconds: 1),
),
);
},
);
},
),
),
],
),
),
);
}
}
onChanged handles filtering as the user types. The controller handles the clear button — you need _searchController.clear() to programmatically empty the visible text field. Without the controller, pressing clear would reset your filtered list but leave the old query text still sitting in the field.
4. onChanged vs TextEditingController — Which to Use
Flutter provides two standard ways to react to text input. Both work for search bars — the choice depends on what else you need:
| Approach | How it works | Best when |
|---|---|---|
onChanged | Callback fired on every keystroke with the current text value | Simple filtering — you only need to react to what the user types |
TextEditingController | Object that can read, set, clear, and listen to the field from code | You also need to programmatically clear, prefill, or listen to the field |
// Option A: onChanged only (simpler — no controller needed, no dispose)
TextField(
onChanged: (query) {
setState(() {
_filteredItems = query.isEmpty
? List.from(_allItems)
: _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
});
},
decoration: const InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
)
// ─────────────────────────────────────────────────────────────────────────
// Option B: TextEditingController (required when you want a clear button)
final _controller = TextEditingController();
TextField(
controller: _controller,
onChanged: _filterItems,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear(); // clears the visible field
_filterItems(''); // resets the filtered list
},
),
border: const OutlineInputBorder(),
),
)
// Always dispose the controller in your dispose() override:
// _controller.dispose();
5. Searching a List of Objects (Not Just Strings)
Real apps search lists of objects — products, contacts, posts. The pattern is identical; you just decide which fields to compare. Here is a complete example with a Product class that filters by name, category, or both:
import 'package:flutter/material.dart';
// 1. Define your data model
class Product {
final String name;
final String category;
final double price;
const Product({required this.name, required this.category, required this.price});
}
void main() {
runApp(const MaterialApp(home: ProductSearchPage()));
}
class ProductSearchPage extends StatefulWidget {
const ProductSearchPage({super.key});
@override
State<ProductSearchPage> createState() => _ProductSearchPageState();
}
class _ProductSearchPageState extends State<ProductSearchPage> {
final TextEditingController _searchController = TextEditingController();
// 2. Source list of objects — never modified
final List<Product> _allProducts = const [
Product(name: 'Wireless Headphones', category: 'Audio', price: 79.99),
Product(name: 'Mechanical Keyboard', category: 'Computing', price: 129.99),
Product(name: 'USB-C Hub', category: 'Computing', price: 39.99),
Product(name: 'Bluetooth Speaker', category: 'Audio', price: 59.99),
Product(name: 'Webcam HD', category: 'Video', price: 89.99),
Product(name: 'Monitor 27"', category: 'Video', price: 299.99),
Product(name: 'Mouse Pad XL', category: 'Computing', price: 19.99),
Product(name: 'Noise Cancelling', category: 'Audio', price: 199.99),
];
List<Product> _filteredProducts = [];
@override
void initState() {
super.initState();
_filteredProducts = List.from(_allProducts);
}
void _filterProducts(String query) {
setState(() {
if (query.trim().isEmpty) {
_filteredProducts = List.from(_allProducts);
} else {
final lower = query.toLowerCase();
_filteredProducts = _allProducts.where((product) {
// 3. Search across multiple fields — name AND category
return product.name.toLowerCase().contains(lower) ||
product.category.toLowerCase().contains(lower);
}).toList();
}
});
}
void _clearSearch() {
_searchController.clear();
_filterProducts('');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Product Search')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
onChanged: _filterProducts,
decoration: InputDecoration(
hintText: 'Search by name or category...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
),
border: const OutlineInputBorder(),
),
),
),
Expanded(
child: _filteredProducts.isEmpty
? const Center(child: Text('No products found'))
: ListView.builder(
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final product = _filteredProducts[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.deepPurple.shade100,
child: Text(
product.category[0],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.deepPurple,
),
),
),
title: Text(product.name),
subtitle: Text(product.category),
trailing: Text(
'$${product.price.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
);
},
),
),
],
),
);
}
}
Always call
.toLowerCase() on both the query and the field you are searching — product.name.toLowerCase().contains(query.toLowerCase()). This makes search feel natural: typing “audio”, “Audio”, or “AUDIO” all return the same results. Forgetting this is a very common beginner oversight.
6. Filtering API Data with a Search Bar
The two-list pattern works identically for API data. Fetch once into _allItems, then filter into _filteredItems exactly like local data. The key mistake to avoid: never try to filter before the API response arrives. Always filter after you have the full dataset loaded:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// Model matching JSONPlaceholder /users response
class User {
final int id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
class ApiSearchPage extends StatefulWidget {
const ApiSearchPage({super.key});
@override
State<ApiSearchPage> createState() => _ApiSearchPageState();
}
class _ApiSearchPageState extends State<ApiSearchPage> {
final TextEditingController _searchController = TextEditingController();
List<User> _allUsers = []; // source list — populated after fetch
List<User> _filteredUsers = []; // display list — rebuilt on each query
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchUsers();
}
Future<void> _fetchUsers() async {
try {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body) as List;
setState(() {
// 1. Store full API response in _allUsers
_allUsers = data.map((json) => User.fromJson(json as Map<String, dynamic>)).toList();
// 2. Initialise display list with everything
_filteredUsers = List.from(_allUsers);
_isLoading = false;
});
} else {
setState(() {
_error = 'Failed to load: ${response.statusCode}';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'Network error: $e';
_isLoading = false;
});
}
}
void _filterUsers(String query) {
setState(() {
if (query.trim().isEmpty) {
_filteredUsers = List.from(_allUsers);
} else {
final lower = query.toLowerCase();
// 3. Always filter FROM _allUsers — same pattern as local data
_filteredUsers = _allUsers.where((user) {
return user.name.toLowerCase().contains(lower) ||
user.email.toLowerCase().contains(lower);
}).toList();
}
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Search Users (API)')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
onChanged: _filterUsers,
enabled: !_isLoading, // disable while data is loading
decoration: InputDecoration(
hintText: 'Search by name or email...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_filterUsers('');
},
),
border: const OutlineInputBorder(),
),
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: _filteredUsers.isEmpty
? const Center(child: Text('No users match your search'))
: ListView.builder(
itemCount: _filteredUsers.length,
itemBuilder: (context, index) {
final user = _filteredUsers[index];
return ListTile(
leading: CircleAvatar(child: Text('${user.id}')),
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
),
],
),
);
}
}
The API example uses the
http package. Add it before running:dependencies: http: ^1.2.1Then run
flutter pub get and do a full restart. If you hit build errors after adding the package, our Top 10 Flutter Build Errors guide covers the most common causes.
7. Search Bar Inside the AppBar
Many apps put the search bar inside the AppBar itself rather than the body. You can do this by replacing the title with a TextField when search mode is active. Tap the search icon to reveal the field; tap close to dismiss it:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: AppBarSearchPage()));
}
class AppBarSearchPage extends StatefulWidget {
const AppBarSearchPage({super.key});
@override
State<AppBarSearchPage> createState() => _AppBarSearchPageState();
}
class _AppBarSearchPageState extends State<AppBarSearchPage> {
final TextEditingController _searchController = TextEditingController();
bool _isSearching = false; // toggles between static title and search field
final List<String> _allItems = [
'Flutter', 'Dart', 'Widget', 'State', 'Provider',
'Riverpod', 'Bloc', 'Navigator', 'Material', 'Cupertino',
];
List<String> _filteredItems = [];
@override
void initState() {
super.initState();
_filteredItems = List.from(_allItems);
}
void _filterItems(String query) {
setState(() {
_filteredItems = query.isEmpty
? List.from(_allItems)
: _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
});
}
void _stopSearching() {
setState(() {
_isSearching = false;
_searchController.clear();
_filteredItems = List.from(_allItems);
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// Toggle between static title and an active search TextField
title: _isSearching
? TextField(
controller: _searchController,
autofocus: true, // keyboard opens immediately when search starts
onChanged: _filterItems,
style: const TextStyle(color: Colors.white),
cursorColor: Colors.white,
decoration: const InputDecoration(
hintText: 'Search...',
hintStyle: TextStyle(color: Colors.white70),
border: InputBorder.none, // no border inside AppBar
),
)
: const Text('Flutter Topics'),
actions: [
_isSearching
? IconButton(
icon: const Icon(Icons.close),
onPressed: _stopSearching,
tooltip: 'Close search',
)
: IconButton(
icon: const Icon(Icons.search),
onPressed: () => setState(() => _isSearching = true),
tooltip: 'Open search',
),
],
),
body: _filteredItems.isEmpty
? const Center(child: Text('No results found'))
: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.code),
title: Text(_filteredItems[index]),
),
),
);
}
}
8. Material 3 Built-In SearchBar Widget
Flutter 3.10+ ships a dedicated SearchBar widget as part of the Material 3 spec. It pairs with SearchAnchor to produce a full animated search-view experience. This is the alternative to the manual TextField approach — less boilerplate, more built-in animation, but less control over styling:
| Approach | Best for | Control level |
|---|---|---|
TextField + setState (sections 2–7) | Inline filtering — results appear below the field | Full — you own all the styling and logic |
SearchBar + SearchAnchor (M3) | Expanding search view with animated transition | Less — M3 controls the look and animation |
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: M3SearchPage(),
// useMaterial3 defaults to true in Flutter 3.16+
));
}
class M3SearchPage extends StatelessWidget {
const M3SearchPage({super.key});
static const List<String> _allItems = [
'Apple', 'Avocado', 'Banana', 'Blueberry', 'Cherry',
'Coconut', 'Date', 'Elderberry', 'Fig', 'Grapes',
'Kiwi', 'Lemon', 'Mango', 'Orange', 'Pineapple',
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('M3 Search Demo')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
// SearchAnchor manages the open/close transition for the search view
child: SearchAnchor(
builder: (context, controller) {
return SearchBar(
controller: controller,
hintText: 'Search fruits...',
leading: const Icon(Icons.search),
onTap: () => controller.openView(), // tap opens the full view
onChanged: (_) => controller.openView(), // typing also opens the view
);
},
// suggestionsBuilder is called every time the query changes
suggestionsBuilder: (context, controller) {
final query = controller.text.toLowerCase();
final matches = _allItems
.where((item) => item.toLowerCase().contains(query))
.toList();
if (matches.isEmpty) {
return [const ListTile(title: Text('No results found'))];
}
return matches.map((item) => ListTile(
leading: const Icon(Icons.label_outline),
title: Text(item),
onTap: () {
controller.closeView(item); // closes the view and sets the text
},
)).toList();
},
),
),
),
);
}
}
Use the TextField + setState pattern (sections 2–7) when you need the search results inline with the rest of your screen content — the most common case for beginners. Use SearchBar + SearchAnchor when you want a dedicated expanding search view with Material 3 animations and less custom styling — common in e-commerce or content discovery apps.
9. Performance Tips for Large Lists
The TextField + setState pattern works well for lists under a few thousand items. For larger datasets, consider these optimisations:
import 'dart:async';
import 'package:flutter/material.dart';
// ── Tip 1: Debounce — wait 300ms after last keystroke before filtering ──────
// Reduces setState calls from one per character to one per "burst" of typing
class _DebouncedSearchState extends State<DebouncedSearchPage> {
Timer? _debounce;
final List<String> _allItems = [/* your large list */];
List<String> _filteredItems = [];
@override
void initState() {
super.initState();
_filteredItems = List.from(_allItems);
}
void _onSearchChanged(String query) {
// Cancel the previous timer if still running
_debounce?.cancel();
// Start a new 300ms timer
_debounce = Timer(const Duration(milliseconds: 300), () {
setState(() {
_filteredItems = query.isEmpty
? List.from(_allItems)
: _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
});
});
}
@override
void dispose() {
_debounce?.cancel(); // always cancel the timer
super.dispose();
}
}
// ── Tip 2: Always use ListView.builder, never ListView with children ─────────
// ListView.builder renders only visible items — essential for large results
// ❌ Slow — renders ALL filtered items at once even if 500 match
ListView(children: _filteredItems.map((i) => Text(i)).toList())
// ✅ Fast — only renders the ~8–10 items visible on screen
ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) => Text(_filteredItems[index]),
)
// ── Tip 3: Pre-lowercase your source list for very large datasets ────────────
// Avoids calling .toLowerCase() on every item on every keystroke
final List<String> _allItemsLower =
_allItems.map((e) => e.toLowerCase()).toList(); // computed once
void _filterItemsFast(String query) {
final lower = query.toLowerCase(); // computed once per query
setState(() {
_filteredItems = query.isEmpty
? List.from(_allItems)
: [
for (int i = 0; i < _allItems.length; i++)
if (_allItemsLower[i].contains(lower)) _allItems[i],
];
});
}
10. Common Beginner Mistakes
Mistake 1: Filtering the source list directly — clears are broken forever
// ❌ Wrong — filtering _allItems directly destroys the original data
void _filterItems(String query) {
setState(() {
_allItems = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList(); // _allItems now has fewer items — can never be restored!
});
}
// ✅ Correct — always filter FROM _allItems INTO _filteredItems
void _filterItems(String query) {
setState(() {
_filteredItems = query.isEmpty
? List.from(_allItems) // restore from untouched source
: _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
});
}
Mistake 2: setState called in the wrong widget — ListView never updates
// ❌ Wrong — TextField is in WidgetA, _filteredItems lives in WidgetB.
// Calling setState in WidgetA rebuilds only WidgetA.
// The ListView in WidgetB never sees the updated query.
// ✅ Correct — the TextField, _allItems, _filteredItems, and ListView.builder
// must ALL live in the same StatefulWidget.
// setState() in that widget rebuilds the whole tree including the list.
class _SearchPageState extends State<SearchPage> {
List<String> _allItems = [...];
List<String> _filteredItems = [];
// TextField AND ListView.builder both inside this widget's build()
// setState() here rebuilds both together — the list always updates
}
Mistake 3: Missing Expanded — layout error with ListView inside Column
// ❌ Wrong — throws "RenderFlex children have non-zero flex but incoming
// height constraints are unbounded"
Column(
children: [
TextField(...),
ListView.builder(...), // no bounded height → layout error
],
)
// ✅ Correct — Expanded gives ListView the remaining vertical space
Column(
children: [
TextField(...),
Expanded( // ← always required
child: ListView.builder(...),
),
],
)
Mistake 4: Clear button doesn’t work — controller and filter out of sync
// ❌ Wrong — only clears the visible field, filtered list stays narrow
void _clearSearch() {
_searchController.clear(); // field looks empty visually
// but _filteredItems still holds the previous search results!
}
// ✅ Correct — always reset both together
void _clearSearch() {
_searchController.clear(); // empties the visible text field
_filterItems(''); // resets _filteredItems to the full source list
}
Mistake 5: Forgetting to initialise _filteredItems in initState
// ❌ Wrong — _filteredItems starts empty so the list appears blank on load
class _SearchPageState extends State<SearchPage> {
final List<String> _allItems = ['Apple', 'Banana', ...];
List<String> _filteredItems = []; // empty — user sees nothing until they type
// No initState override
}
// ✅ Correct — initialise _filteredItems so the full list shows immediately
@override
void initState() {
super.initState();
_filteredItems = List.from(_allItems); // ← all items visible from the start
}
Mistake 6: Case-sensitive comparison — “apple” finds nothing when item is “Apple”
// ❌ Wrong — case-sensitive: typing "apple" won't match "Apple"
_filteredItems = _allItems
.where((item) => item.contains(query))
.toList();
// ✅ Correct — normalise both sides to lowercase first
_filteredItems = _allItems
.where((item) => item.toLowerCase().contains(query.toLowerCase()))
.toList();
11. Interview Q&A
A: Use a
TextField with onChanged, keep a source list and a filtered list, and update the filtered list inside onChanged with setState(). Show the filtered list in a ListView.builder. This is the standard pattern for real-time local filtering and requires no external packages.
A: Because filtering the source list directly removes items permanently. If you filter
_allItems in place, clearing the search cannot restore the full list — those items are gone. Keeping _allItems unchanged and only modifying _filteredItems means you can always rebuild the full list from the original data.
A: Both are valid.
onChanged is simpler — use it when you only need to react to what the user types. TextEditingController is necessary when you also need to programmatically clear the field (for a clear button), prefill it with a value, or listen to changes from outside the widget. For a full search bar with a clear button, use both together: onChanged for filtering and a controller for the clear button.
A: Check whether
_filteredItems.isEmpty and render a fallback widget instead of the ListView. Use a ternary in the Expanded child: if empty show a Center with a message and optional clear button; if not empty show ListView.builder. This is both a UX improvement and a common expectation in interviews about search bar implementations.
A: Fetch the full API response into a source list first (
_allUsers). Then filter that source list into a display list (_filteredUsers) using the same two-list pattern as local data — onChanged triggers the filter, setState rebuilds the UI. Never try to filter before the API response arrives; always wait for the source list to be populated first.
A: Almost always because
setState() is being called in the wrong widget. The TextField, the source list, the filtered list, and the ListView.builder must all live in the same StatefulWidget. If the TextField is in a child widget and the list is in a parent or sibling, the setState in the child only rebuilds the child — the list never sees the updated query.
A: The manual
TextField approach gives you full control over layout, filtering logic, and styling — the results appear inline with the rest of your screen. Flutter’s built-in SearchBar widget (Material 3, Flutter 3.10+) pairs with SearchAnchor to create an animated expanding search view. Use TextField for inline filtering; use SearchBar + SearchAnchor when you want the full-screen Material 3 search experience with less custom code.
12. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets Explained: Stateless vs Stateful | The entire search bar depends on StatefulWidget and setState() — read this first if either concept is unclear. |
| Handling User Input: TextField, Forms and Validation | TextField and TextEditingController are at the heart of the search bar — this guide covers both in depth. |
| Flutter ListView.builder: The Complete Beginner’s Guide | The filtered results are displayed in ListView.builder — this guide covers all its patterns including pagination, pull-to-refresh, and Dismissible. |
| Flutter Layout Made Easy: Row, Column, Flex and Expanded | Explains Expanded inside Column — essential for understanding and fixing the most common search bar layout error. |
| Flutter Bottom Navigation Bar: Complete Beginner’s Guide | The Search tab in a nav bar app is the perfect home for this search bar — see how to wire both together. |
| Top 10 Flutter Build Errors Beginners See | The API search example requires the http package — if you hit build errors after adding it, this guide has the fixes. |
