Block Pattern

Flutter Tutorial: Debounced Search with JSONPlaceholder Users API

In modern mobile apps, especially those with dynamic search features, debouncing is crucial to avoid sending too many network requests while the user types. In this tutorial, you’ll learn how to implement a debounced search in Flutter by fetching data from the https://jsonplaceholder.typicode.com/users API.

We will:

  • Fetch and display user data.
  • Add a search input with debounce delay.
  • Filter users by name, username, email, city, or company.

🚀 Final Preview

We’ll build a simple screen with:

  • A search bar at the top
  • A list of users below
  • Debounced filtering as you type

🧰 Step 1: Set Up Dependencies

In pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
    

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  flutter_bloc: ^9.1.0
  hydrated_bloc: ^10.0.0
  path_provider: ^2.0.15
  http: ^1.4.0

 

Run:

flutter pub get

🏗️ Step 2: Create the User Model

Create a file models/user_model.dart:

class User {
  final int id;
  final String name;
  final String username;
  final String email;
  final String city;
  final String company;

  User({
    required this.id,
    required this.name,
    required this.username,
    required this.email,
    required this.city,
    required this.company,
  });

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'],
      name: json['name'],
      username: json['username'],
      email: json['email'],
      city: json['address']['city'],
      company: json['company']['name'],
    );
  }
}

 


🌐 Step 3: Create the API Service

Create a file services/api_service.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/user_model.dart';

class ApiService {
  static const String url = 'https://jsonplaceholder.typicode.com/users';

  static Future<List<User>> fetchUsers() async {
    final response = await http.get(Uri.parse(url));
    if (response.statusCode == 200) {
      final List jsonData = json.decode(response.body);
      return jsonData.map((user) => User.fromJson(user)).toList();
    } else {
      throw Exception('Failed to load users');
    }
  }
}

 


⏳ Step 4: Add Debounce Utility

Create a utility utils/debouncer.dart:

import 'dart:async';

import 'package:flutter/foundation.dart';

class Debouncer {
  final int milliseconds;
  VoidCallback? action;
  Timer? _timer;

  Debouncer({this.milliseconds = 500});

  run(VoidCallback action) {
    _timer?.cancel();
    _timer = Timer(Duration(milliseconds: milliseconds), action);
  }
}

 


🖼️ Step 5: Build the UI with Debounced Search

Create a screen screens/user_list_screen.dart:

import 'package:flutter/material.dart';
import '../models/user_model.dart';
import '../services/api_service.dart';
import '../utils/debouncer.dart';

class UserListScreen extends StatefulWidget {
  const UserListScreen({Key? key}) : super(key: key);

  @override
  _UserListScreenState createState() => _UserListScreenState();
}

class _UserListScreenState extends State<UserListScreen> {
  final Debouncer _debouncer = Debouncer(milliseconds: 500);
  List<User> _users = [];
  List<User> _filteredUsers = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadUsers();
  }

  void _loadUsers() async {
    final users = await ApiService.fetchUsers();
    setState(() {
      _users = users;
      _filteredUsers = users;
      _isLoading = false;
    });
  }

  void _filterUsers(String query) {
    _debouncer.run(() {
      final filtered = _users.where((user) {
        final lowerQuery = query.toLowerCase();
        return user.name.toLowerCase().contains(lowerQuery) ||
            user.username.toLowerCase().contains(lowerQuery) ||
            user.email.toLowerCase().contains(lowerQuery) ||
            user.city.toLowerCase().contains(lowerQuery) ||
            user.company.toLowerCase().contains(lowerQuery);
      }).toList();

      setState(() {
        _filteredUsers = filtered;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Debounced User Search')),
      body: _isLoading
          ? Center(child: CircularProgressIndicator())
          : Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(12.0),
                  child: TextField(
                    onChanged: _filterUsers,
                    decoration: InputDecoration(
                      hintText: 'Search by name, email, city...',
                      prefixIcon: Icon(Icons.search),
                      border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(8)),
                    ),
                  ),
                ),
                Expanded(
                  child: ListView.builder(
                    itemCount: _filteredUsers.length,
                    itemBuilder: (context, index) {
                      final user = _filteredUsers[index];
                      return ListTile(
                        title: Text(user.name),
                        subtitle: Text('${user.email} - \${user.city}'),
                        trailing: Text(user.company),
                      );
                    },
                  ),
                )
              ],
            ),
    );
  }
}

 


📱 Step 6: Add to main.dart

import 'package:flutter/material.dart';
import 'screens/user_list_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Debounced Search',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const UserListScreen(),
      debugShowCheckedModeBanner: false,
    );
  }
}

 


✅ Done! Run the App

Now launch the app:

flutter run

Start typing in the search bar — you’ll notice it only triggers filtering after you pause typing for 500ms.


Leave a Reply

Your email address will not be published. Required fields are marked *