Flutter

Search

SearchBar widget, TextField with onChange, filtering list in memory, FutureBuilder with API search, and debounce pattern.

SearchBar Widget (Material 3)

The SearchBar widget is a Material 3 component that provides a built-in search UI with a controller and callbacks.

Dart
class MySearchPage extends StatefulWidget {
  const MySearchPage({super.key});
  @override
  State<MySearchPage> createState() => _MySearchPageState();
}

class _MySearchPageState extends State<MySearchPage> {
  final SearchController _searchController = SearchController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Search Demo')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: SearchBar(
          controller: _searchController,
          hintText: 'Search something...',
          leading: const Icon(Icons.search),
          trailing: [
            IconButton(
              icon: const Icon(Icons.clear),
              onPressed: () => _searchController.clear(),
            ),
          ],
          onChanged: (value) {
            print('Query: $value');
          },
          onSubmitted: (value) {
            print('Submitted: $value');
          },
        ),
      ),
    );
  }
}

TextField with onChanged

Use a plain TextField with onChanged to react to every keystroke. Pair it with a TextEditingController for full control.

Dart
class TextFieldSearch extends StatefulWidget {
  const TextFieldSearch({super.key});
  @override
  State<TextFieldSearch> createState() => _TextFieldSearchState();
}

class _TextFieldSearchState extends State<TextFieldSearch> {
  final TextEditingController _controller = TextEditingController();
  String _query = '';

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        hintText: 'Type to search...',
        prefixIcon: const Icon(Icons.search),
        suffixIcon: _query.isNotEmpty
            ? IconButton(
                icon: const Icon(Icons.clear),
                onPressed: () {
                  _controller.clear();
                  setState(() => _query = '');
                },
              )
            : null,
        border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
      ),
      onChanged: (value) {
        setState(() => _query = value);
      },
    );
  }
}

Filtering a List in Memory

Keep an original list and a filtered list in state. On every query change, re-filter the original list using where() and rebuild.

Dart
class FilterableList extends StatefulWidget {
  const FilterableList({super.key});
  @override
  State<FilterableList> createState() => _FilterableListState();
}

class _FilterableListState extends State<FilterableList> {
  final List<String> _allItems = [
    'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
    'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
  ];
  List<String> _filtered = [];

  @override
  void initState() {
    super.initState();
    _filtered = List.from(_allItems);
  }

  void _filterList(String query) {
    setState(() {
      _filtered = _allItems
          .where((item) => item.toLowerCase().contains(query.toLowerCase()))
          .toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          decoration: const InputDecoration(
            hintText: 'Filter fruits...',
            prefixIcon: Icon(Icons.filter_list),
          ),
          onChanged: _filterList,
        ),
        const SizedBox(height: 8),
        Expanded(
          child: ListView.builder(
            itemCount: _filtered.length,
            itemBuilder: (context, index) => ListTile(
              title: Text(_filtered[index]),
            ),
          ),
        ),
      ],
    );
  }
}

FutureBuilder with API Search

Use FutureBuilder to call an API on demand. Store the future in state and rebuild with the new future when the user submits a query.

Dart
Future<List<String>> searchApi(String query) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/search?q=$query'),
  );
  if (response.statusCode == 200) {
    final data = jsonDecode(response.body) as List;
    return data.map((e) => e['name'] as String).toList();
  }
  throw Exception('Failed to search');
}

class ApiSearchPage extends StatefulWidget {
  const ApiSearchPage({super.key});
  @override
  State<ApiSearchPage> createState() => _ApiSearchPageState();
}

class _ApiSearchPageState extends State<ApiSearchPage> {
  Future<List<String>>? _resultFuture;

  void _search(String query) {
    if (query.isEmpty) return;
    setState(() => _resultFuture = searchApi(query));
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          onSubmitted: _search,
          decoration: const InputDecoration(hintText: 'Press Enter to search...'),
        ),
        const SizedBox(height: 12),
        if (_resultFuture != null)
          FutureBuilder<List<String>>(
            future: _resultFuture,
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const CircularProgressIndicator();
              }
              if (snapshot.hasError) {
                return Text('Error: ${snapshot.error}');
              }
              final results = snapshot.data!;
              return ListView.builder(
                shrinkWrap: true,
                itemCount: results.length,
                itemBuilder: (_, i) => ListTile(title: Text(results[i])),
              );
            },
          ),
      ],
    );
  }
}

Debounce Pattern

Debouncing delays the search call until the user stops typing for a set duration. Use a Timer that resets on each keystroke to avoid excessive API calls.

Dart
import 'dart:async';

class DebouncedSearch extends StatefulWidget {
  const DebouncedSearch({super.key});
  @override
  State<DebouncedSearch> createState() => _DebouncedSearchState();
}

class _DebouncedSearchState extends State<DebouncedSearch> {
  Timer? _debounce;
  List<String> _results = [];

  @override
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }

  void _onSearchChanged(String query) {
    // Cancel previous timer if still pending
    if (_debounce?.isActive ?? false) _debounce!.cancel();

    _debounce = Timer(const Duration(milliseconds: 500), () {
      // This block runs 500ms after the user stops typing
      _performSearch(query);
    });
  }

  Future<void> _performSearch(String query) async {
    if (query.isEmpty) {
      setState(() => _results = []);
      return;
    }
    // Replace with real API call
    final results = await searchApi(query);
    setState(() => _results = results);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          onChanged: _onSearchChanged,
          decoration: const InputDecoration(
            hintText: 'Search with debounce...',
            prefixIcon: Icon(Icons.search),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _results.length,
            itemBuilder: (_, i) => ListTile(title: Text(_results[i])),
          ),
        ),
      ],
    );
  }
}