Creating a REST API in Flutter with Stream: An Alternative to setState

mateus-ic1101

Mateus 🇧🇷

Posted on October 3, 2024

Creating a REST API in Flutter with Stream: An Alternative to setState

This is the first post on my profile, and I will talk about streams in Flutter with an example of using them to consume data from an API.

What is a stream and how does it work?

In Dart, a Stream is a way to handle asynchronous data over time, similar to an event pipeline. It allows you to listen to data as it becomes available, making it perfect for scenarios where you expect multiple values over time, like receiving API responses or handling user input.

Instead of waiting for a single value (like with Future), Stream emits a sequence of values, either one after the other (for example, real-time data from a REST API) or intermittently. You can "subscribe" to a stream using a listener, and each time a new value arrives, the listener triggers a function. This makes streams a powerful alternative to setState, allowing your UI to react dynamically to changes without manual state updates.

First Steps

First, we will create a new Flutter project. Use the following command to create the project with the name app_stream_api_users:
flutter create app_stream_api_users

Next, we will install the package to make the API call. The package will be http. To add it to your project, use the following command:
flutter pub add http

Next, we'll create a class to handle the API call. In the example, I used Dart's native call method, which allows you to execute the class simply by instantiating it, without needing to specify a method name.

import 'package:app_stream_api_users/dto/user_dto.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;

class UserHttp {
  final String apiUrl = 'https://reqres.in/api/users';

  Future<List<UserDTO>> call() async {
    final response = await http.get(Uri.parse('$apiUrl'));

    if (response.statusCode == 200) {
      final List<dynamic> jsonData = jsonDecode(response.body)['data'];
      return jsonData.map((user) => UserDTO.fromJson(user)).toList();
    } else {
      throw Exception('Failed to load users');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we will create a Data Transfer Object (DTO), which is a concept similar to a model. The purpose of the DTO is to represent the data structure we will work with when consuming data from the API. It will help us efficiently manage the data we receive, making it easier to analyze and use throughout our application.

class UserDTO {
  final int id;
  final String email;
  final String firstName;
  final String lastName;
  final String avatar;

  UserDTO({
    required this.id,
    required this.email,
    required this.firstName,
    required this.lastName,
    required this.avatar,
  });

  factory UserDTO.fromJson(Map<String, dynamic> json) {
    return UserDTO(
      id: json['id'],
      email: json['email'],
      firstName: json['first_name'],
      lastName: json['last_name'],
      avatar: json['avatar'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'email': email,
      'first_name': firstName,
      'last_name': lastName,
      'avatar': avatar,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, I won’t create many files. I will place the following code directly in the main.dart:

import 'dart:async';
import 'package:app_stream_api_users/dto/user_dto.dart';
import 'package:app_stream_api_users/http/get_all_users_http.dart';
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter App Stream',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter App Stream'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final StreamController<List<UserDTO>> _controllerUser =
      StreamController<List<UserDTO>>.broadcast();

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

  @override
  void dispose() {
    super.dispose();
    _controllerUser.close();
  }

  void getUsers() async {
    final UserHttp http = UserHttp();
    List<UserDTO> users = await http();
    _controllerUser.add(users);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: StreamBuilder<List<UserDTO>>(
          stream: _controllerUser.stream,
          initialData: [],
          builder: (context, snapshot) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                const Text(
                  'The current value is:',
                ),
                StreamBuilder<List<UserDTO>>(
                  stream: _controllerUser.stream,
                  initialData: [], // Começa com uma lista vazia
                  builder: (context, snapshot) {
                    if (snapshot.connectionState == ConnectionState.waiting) {
                      return const CircularProgressIndicator(); // Mostra um loading enquanto carrega
                    } else if (snapshot.hasError) {
                      return const Text('Error loading users');
                    } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
                      return const Text('No users found');
                    } else {
                      final users = snapshot.data!;
                      return Expanded(
                        child: ListView.builder(
                          itemCount: users.length,
                          itemBuilder: (context, index) {
                            final UserDTO user = users[index];
                            return ListTile(
                              leading: CircleAvatar(
                                backgroundImage: NetworkImage(user.avatar),
                              ),
                              title: Text('${user.firstName} ${user.lastName}'),
                              subtitle: Text(user.email),
                            );
                          },
                        ),
                      );
                    }
                  },
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the code

Here, we create our StreamController, which will be typed with the data returned from our API. At the end, the broadcast() function is used, which allows multiple listeners to subscribe to the stream simultaneously.

final StreamController<List<UserDTO>> _controllerUser =
      StreamController<List<UserDTO>>.broadcast();
Enter fullscreen mode Exit fullscreen mode

Next, we create the function for our StreamController to receive data from the API, and we place it in initState, which is the method called to execute initialization tasks that are essential for the widget's functionality, allowing us to run code before the build() method. We close the StreamController after the widget is destroyed to prevent memory leaks. This is done in the dispose method.

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

  @override
  void dispose() {
    super.dispose();
    _controllerUser.close();
  }

  void getUsers() async {
    final UserHttp http = UserHttp();
    List<UserDTO> users = await http();
    _controllerUser.add(users);
  }
Enter fullscreen mode Exit fullscreen mode

And finally, the StreamBuilder listens to the _controllerUser stream and updates the UI with the latest user data. It handles loading states, errors, and empty data gracefully. If there are users, it displays them in a scrollable list with their avatars and details.

StreamBuilder<List<UserDTO>>(
          stream: _controllerUser.stream,
          initialData: [],
          builder: (context, snapshot) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                const Text(
                  'The current value is:',
                ),
                StreamBuilder<List<UserDTO>>(
                  stream: _controllerUser.stream,
                  initialData: [], // Começa com uma lista vazia
                  builder: (context, snapshot) {
                    if (snapshot.connectionState == ConnectionState.waiting) {
                      return const CircularProgressIndicator(); // Mostra um loading enquanto carrega
                    } else if (snapshot.hasError) {
                      return const Text('Error loading users');
                    } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
                      return const Text('No users found');
                    } else {
                      final users = snapshot.data!;
                      return Expanded(
                        child: ListView.builder(
                          itemCount: users.length,
                          itemBuilder: (context, index) {
                            final UserDTO user = users[index];
                            return ListTile(
                              leading: CircleAvatar(
                                backgroundImage: NetworkImage(user.avatar),
                              ),
                              title: Text('${user.firstName} ${user.lastName}'),
                              subtitle: Text(user.email),
                            );
                          },
                        ),
                      );
                    }
                  },
                ),
              ],
            );
          },
        ),
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
mateus-ic1101
Mateus 🇧🇷

Posted on October 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related