Como lidar com estados de carregamento e erro com StateNotifier e AsyncValue no Flutter
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.
Conteudo
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 carregando →
AsyncValue.data
- carregando →
AsyncValue.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 usarAsyncValue<void>
ao definir nossoStateNotifier
eAsyncValue.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()
estate.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 deAsyncValue
) - passamos a variável
isLoading
para oPrimaryButton
, 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 viaref.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