Como lidar com estados de carregamento e erro com StateNotifier e AsyncValue no Flutter

Tempo de leitura: 6 minutes

Os estados de carregamento e de erro são muito comuns em aplicativos que realizam algum trabalho assíncrono.

Se não conseguirmos mostrar uma interface de carregamento ou de erro quando apropriado, os usuários poderão pensar que o aplicativo não está funcionando e não saberão se a operação que estão tentando realizar foi bem-sucedida.

Por exemplo, aqui está uma página com um botão que podemos usar para pagar um produto com Stripe:

Como podemos ver, um indicador de carregamento é apresentado assim que o botão “Pay” é pressionado. E a própria planilha de pagamento também mostra um indicador de carregamento até que os meios de pagamento estejam disponíveis.

E se o pagamento falhar por algum motivo, devemos mostrar algum erro na UI para informar o usuário.

Então, vamos nos aprofundar e aprender como podemos lidar com essas preocupações em nossos aplicativos Flutter.

 

Carregamento e estados de erro usando StatefulWidget

Os estados de carregamento e de erro são muito comuns e devemos tratá-los em cada página ou widget que execute algum trabalho assíncrono.

Por exemplo, suponha que temos um PaymentButton que podemos usar para efetuar um pagamento:

class PaymentButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // note: esta é uma classe de botão *personalizada* que recebe um argumento `isLoading` extra
    return PrimaryButton(
      text: 'Pay',
      // isso mostrará um botão giratório se o carregamento for verdadeiro
      isLoading: false,
      onPressed: () {
        // use um localizador ou provedor de serviço para obter o serviço de checkout
        // faça o pagamento
      },
    );
  }
}

Se quiséssemos, poderíamos tornar este widget com estado e adicionar duas variáveis de estado:

class _PaymentButtonState extends State<PaymentButton> {
  // Variáveis loading e error state
  bool _isLoading = false;
  String _errorMessage = '';
  
  Future<void> pay() async {
    // efetuar o pagamento, atualizar variáveis de estado e mostrar um alerta em caso de erro
  }

  @override
  Widget build(BuildContext context) {
    // o mesmo de antes, 
    return PrimaryButton(
      text: 'Pay',
      // use a variável _isLoading definida acima
      isLoading: _isLoading,
      onPressed: _isLoading ? null : pay,
    );
  }
}

Essa abordagem funcionará, mas é bastante repetitiva e propensa a erros.

Afinal, não queremos tornar todos os nossos widgets com estado e adicionar variáveis de estado em todos os lugares, certo?

 

Tornando os estados de carregamento e erro mais simples

O que realmente queremos é uma maneira consistente de gerenciar estados de carregamento e erro em todo o aplicativo.

Para fazer isso, usaremos AsyncValue e StateNotifier do pacote Riverpod.

Quando terminarmos, poderemos mostrar qualquer interface de carregamento e erro com algumas linhas de código, assim:

class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // manipulação de erros
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.showSnackBarOnError(context),
    );
    final paymentState = ref.watch(paymentButtonControllerProvider);
    // note: esta é uma classe de botão *personalizada* que recebe um argumento `isLoading` extra
    return PrimaryButton(
      text: 'Pay',
      // mostre um botão giratório se loading for verdadeiro
      isLoading: paymentState.isLoading,
      // botão desativar se o carregamento for verdadeiro
      onPressed: paymentState.isLoading
        ? null
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

Mas vamos dar um passo de cada vez.

 

Configuração básica: o widget PaymentButton

Vamos começar com o widget PaymentButton básico que apresentamos anteriormente:

import 'package:flutter_riverpod/flutter_riverpod.dart';

// nota: desta vez subclassificamos de ConsumerWidget para que possamos obter um WidgetRef abaixo
class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // nota: Esta é uma classe de botão personalizada que recebe um argumento `isLoading` extra
    return PrimaryButton(
      text: 'Pay',
      isLoading: false,
      onPressed: () => ref.read(checkoutServiceProvider).pay(),
    );
  }
}

Quando o botão é pressionado, chamamos ref.read() para obter o serviço de checkout e usá-lo para pagar.

Para referência, veja como o CheckoutService e o provedor correspondente podem ser implementados:

// interface de exemplo para o serviço de checkout
abstract class CheckoutService {
  // isso terá sucesso ou gerará um erro
  Future<void> pay();
}

final checkoutServiceProvider = Provider<CheckoutService>((ref) {
  // retornar alguma implementação concreta do CheckoutService
});

Isso funciona, mas o método pay() pode levar alguns segundos e não temos nenhum carregamento ou interface de erro em vigor.

Vamos abordar isso.

 

Gerenciando estados de carregamento e erro com AsyncValue

A IU em nosso exemplo precisa gerenciar três estados possíveis:

  • não carregando (padrão) (not loading)
  • carregando (loading)
  • erro (error)

Para representar esses estados, podemos usar a classe AsyncValue que acompanha o pacote Riverpod.

Para referência, aqui está como esta classe é definida:

@sealed
@immutable
abstract class AsyncValue<T> {
  const factory AsyncValue.data(T value) = AsyncData<T>;
  const factory AsyncValue.loading() = AsyncLoading<T>;
  const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) =
        AsyncError<T>;
}

Observe que esta classe é abstrata e só podemos instanciá-la usando um dos construtores de fábrica existentes.

E nos bastidores, esses construtores são implementados com as seguintes classes concretas:

class AsyncData<T> implements AsyncValue<T>
class AsyncLoading<T> implements AsyncValue<T>
class AsyncError<T> implements AsyncValue<T>

O que mais importa é que podemos usar AsyncValue para representar os três estados que nos interessam:

  • não carregandoAsyncValue.data
  • carregandoAsyncValue.loading
  • erro AsyncValue.error

Mas onde devemos colocar nossa lógica?

Para isso, precisamos definir uma subclasse StateNotifier que utilizará o AsyncValue<void> como estado.

Subclasse StateNotifier

Primeiro, definiremos uma classe PaymentButtonController que usa CheckoutService como dependência e define o estado padrão:

class PaymentButtonController extends StateNotifier<AsyncValue<void>> {
  PaymentButtonController({required this.checkoutService})
      // inicializar estado
      : super(const AsyncValue.data(null));
  final CheckoutService checkoutService;
}

Nota: AsyncValue.data() normalmente é usado para transportar alguns dados usando um argumento <T> genérico. Mas no nosso caso não temos nenhum dado, então podemos usar AsyncValue<void> ao definir nosso StateNotifier e AsyncValue.data(null) ao definir o valor inicial.

Então, podemos adicionar um método pay() que será chamado a partir da classe do widget:

Future<void> pay() async {
    try {
      // defina o estado como `loading` antes de iniciar o trabalho assíncrono
      state = const AsyncValue.loading();
      // faça o trabalho assíncrono
      await checkoutService.pay();
    } catch (e) {
      // se o pagamento falhar, defina o estado de erro
      state = const AsyncValue.error('Could not place order');
    } finally {
      // defina o estado como `data(null)` no final (tanto para sucesso quanto para falha)
      state = const AsyncValue.data(null);
    }
  }
}

Observe como o estado é definido várias vezes para que nosso widget possa reconstruir e atualizar a IU de acordo.

Para disponibilizar o PaymentButtonController para nosso widget, podemos definir um StateNotifierProvider assim:

final paymentButtonControllerProvider =
    StateNotifierProvider<PaymentButtonController, AsyncValue<void>>((ref) {
  final checkoutService = ref.watch(checkoutServiceProvider);
  return PaymentButtonController(checkoutService: checkoutService);
});

 

Widget PaymentButton atualizado

Agora que temos um PaymentButtonController, podemos usá-lo em nossa classe de widget:

class PaymentButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. ouça os erros
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.whenOrNull(
        error: (error) {
          // mostrar snackbar se ocorreu um erro
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error)),
          );
        },
      ),
    );
    // 2. use o estado de carregamento no widget filho
    final paymentState = ref.watch(paymentButtonControllerProvider);
    final isLoading = paymentState is AsyncLoading<void>;
    return PrimaryButton(
      text: 'Pay',
      isLoading: isLoading,
      onPressed: isLoading
        ? null
        // nota: isso estava anteriormente usando o serviço de checkout
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

Algumas notas:

  • usamos ref.listen() e state.whenOrNull() para mostrar uma barra de lanche se um estado de erro for encontrado
  • verificamos se o estado do pagamento é uma instância AsyncLoading<void> (lembre-se: AsyncLoading é uma subclasse de AsyncValue)
  • passamos a variável isLoading para o PrimaryButton, que se encarregará de mostrar a UI correta

Isso funciona, mas podemos obter o mesmo resultado com menos código padrão?

 

Extensões Dart para o resgate

Vamos definir uma extensão em AsyncValue<void> para que possamos verificar mais facilmente o estado de carregamento e mostrar uma barra de lanche em caso de erro:

extension AsyncValueUI on AsyncValue<void> {
  // Abreviação isLoading (AsyncLoading é uma subclasse de AsycValue)
  bool get isLoading => this is AsyncLoading<void>;

  // mostrar uma lanchonete apenas em caso de erro
  void showSnackBarOnError(BuildContext context) => whenOrNull(
        error: (error, _) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      );
}

Com essas mudanças, podemos simplificar nossa classe de widget:

class PaymentButton extends ConsumerWidget {
  const PaymentButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. ouça os erros
    ref.listen<AsyncValue<void>>(
      paymentButtonControllerProvider,
      (_, state) => state.showSnackBarOnError(context),
    );
    // 2. use o estado de carregamento no widget filho
    final paymentState = ref.watch(paymentButtonControllerProvider);
    return PrimaryButton(
      text: 'Pay',
      isLoading: paymentState.isLoading,
      onPressed: paymentState.isLoading
        ? null
        : () => ref.read(paymentButtonControllerProvider.notifier).pay(),
    );
  }
}

E com isso implementado, os estados de carregamento e de erro são tratados corretamente para esta página específica:

 

Conclusão

Aqui está a implementação completa da extensão AsyncValueUI:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Bônus: defina AsyncValue<void> como um typedef que podemos
//reutiliza vários widgets e notificadores de estado
typedef VoidAsyncValue = AsyncValue<void>;

extension AsyncValueUI on VoidAsyncValue {
  bool get isLoading => this is AsyncLoading<void>;

  void showSnackBarOnError(BuildContext context) => whenOrNull(
        error: (error, _) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      );
}

Graças aos métodos de extensão AsyncValueUI, podemos lidar facilmente com estados de carregamento e erro em nosso aplicativo.

Na verdade, para cada página que realiza trabalho assíncrono, precisamos seguir dois passos:

  • adicione uma subclasse StateNotifier<VoidAsyncValue> que faça a mediação entre a classe do widget e as classes de serviço ou repositório acima
  • modifique o método build() do widget manipulando o estado de erro via ref.listen() e verificando o estado de carregamento conforme necessário

Embora seja necessário um pouco de trabalho inicial para configurar as coisas desta forma, as vantagens valem a pena:

  • podemos lidar com estados de carregamento e erro com pouco código padrão em nossos widgets
  • podemos mover toda a lógica de gerenciamento de estado de nossos widgets para classes de controladores separadas