Async Redux para Flutter: O Redux sem boilerplate

A maneira fácil de gerenciar estado num aplicativo Flutter.

Marcelo Glasberg
Aug 5, 2019 · 6 min read

O AsyncRedux é uma versão especial de Redux que:

  1. É fácil de aprender
  2. É fácil de usar
  3. É fácil de testar
  4. Não tem boilerplate

Este texto assume que você já conhece Redux e Flutter, e serve apenas para te explicar em linhas gerais porque o é melhor que o Redux comum e pode ser a forma mais fácil para você gerenciar estado nos seus aplicativos. Se você quer todos os detalhes e funcionalidades, por favor veja a publicação do Pacote AsyncRedux para Flutter.

Declare a sua e assim:

var state = AppState.estadoInicial();var store = Store<AppState>(
initialState: state,
);

Para alterar o estado da store, você precisa fazer o “dispatch” de alguma action. No todas actions estendem ReduxAction. Você despacha actions da forma usual:

store.dispatch(IncrementaAction(quantidade: 3));
store.dispatch(BuscaEIncrementaAction());

O reducer de uma action é simplesmente um método da action em si, chamado de reduce. Todas actions precisam fazer override deste método.

Reducer Síncrono

Se você quer rodar um código , simplesmente declare que o reducer retorna AppState, e então mude o estado e o retorne. Por exemplo, esta é uma action simples que incrementa um contador em alguma quantidade:

class IncrementaAction extends ReduxAction<AppState> {
final int quantidade;
IncrementaAction({this.quantidade}) : assert(quantidade != null);
@override
AppState reduce() {
return state.copy(contador: state.contador + quantidade));
}
}

Despachar a action acima é suficiente para rodar o reducer e mudar o estado. Ao contrário de outras versão do Redux, entretanto, aqui não há necessidade de listar funções de middleware na criação da store, nem de ligá-la aos reducers.

Veja o código: Exemplo de Reducer Síncrono.

Reducer Assíncrono

A funcionalidade mais óbvia do é que não é necessário middleware, já que os reducers podem ser tanto síncronos quando assíncronos.

Se você quer rodar um código , simplesmente declare que o reducer retorna Future<AppState>, e então mude o estado como desejado e o retorne.

Como exemplo, suponha que você quer incrementar um contador em uma quantidade que você vai buscar no banco de dados. O acesso ao banco é assíncrono, e portanto você deve usar um reducer assíncrono:

class BuscaEIncrementaAction extends ReduxAction<AppState> {@override
Future<AppState> reduce() async {
int quant = await pegaQuantidade();
return state.copy(contador: state.contador + quant));
}
}

Veja o código: Exemplo de Reducer Assíncrono.

Image for post
Image for post

Mudar o estado é opcional

Tanto para reducers síncronos e assíncronos, devolver um novo estado é opcional. Você pode devolver null, que é o mesmo que devolver o estado inalterado.

Isso é útil porque algumas actions podem simplesmente começar outros processos assíncronos, ou mesmo despachar outras actions. Por exemplo:

class BuscaAction extends ReduxAction<AppState> { 
@override
Future<AppState> reduce() async {
dispatch(IncrementaAction(quant: await pegaQuantidade()));
return null;
}
}

Antes e Depois do Reducer

Às vezes, você pode querer impedir o usuário de interagir com a tela enquanto um reducer assíncrono está executando. Outras vezes, você pode querer checar precondições tais como a presença de conexão internet, e daí não rodar o reducer se essas precondições não se verificam.

Para te ajudar com esses casos de uso, você pode fazer override dos métodos ReduxAction.before() e ReduxAction.after(), que executam respectivamente antes e depois do reducer.

Future<void> before() async => await checarConexaoInternet();

Se o métodobefore() lançar um erro, então o reduce() não vai ser executado. Isso significa que você pode verificar aqui quaisquer precondições, e lançar um erro se quer impedir o reducer de rodar. Esse método também é capaz de despachar actions, e portanto poderia ser usado para ligar uma barreira modal que impeça o usuário de interagir com a tela do dispositivo:

void before() => dispatch(EsperaAction(true));

O método after() roda depois do reduce(), mesmo que um erro seja lançado pelo before() ou reduce() (tal como se fosse um bloco "finally"). Portanto, ele pode ser usado para fazer coisas como desligar uma barreira modal quando o reducer termina, mesmo se ocorrer algum erro durante o processo:

void after() => dispatch(EsperaAction(false));

Um exemplo completo:

// Incrementa um contador, e pega uma descrição.
class IncrementaEPegaDescricaoAction extends ReduxAction<AppState> {

@override
Future<AppState> reduce() async {
dispatch(IncrementaAction());
String descricao = await read("http://numbersapi.com/${state.contador}");
return state.copy(descricao: descricao);
}

void before() => dispatch(EsperaAction(true));

void after() => dispatch(EsperaAction(false));
}

Veja o código: Exemplo Antes e Depois do Reducer.

Você também pode criar classes abstratas com métodos before() e after() pré-definidos. Por exemplo, qualquer action que estenda a classe BarreiraAction abaixo vai exibir uma barreira modal ao rodar:

abstract class BarreiraAction extends ReduxAction<AppState> {
void before() => dispatch(EsperaAction(true));
void after() => dispatch(EsperaAction(false));
}
class IncrementaEPegaDescricaoAction extends BarreiraAction {
@override
Future<AppState> reduce() async { ... }
}

A classe BarreiraAction acima é demonstrada neste exemplo.


Processando erros lançados por Actions

Suponha uma action de “logout” que checa se há conexão com a internet, e daí apaga o banco de dados e faz a store voltar ao seu estado inicial:

class LogoutAction extends ReduxAction<AppState> {      
@override
Future<AppState> reduce() async {
await checaConexaoInternet();
await apagaBancoDeDados();
return AppState.estadoInicial();
}
}

No código acima, a função checaConexaoInternet() lança um erro se não há conexão internet:

Future<void> checaConexaoInternet() async {
if (await Connectivity().checkConnectivity() == ConnectivityResult.none)
throw NoInternetConnectionException();
}

Todos os erros lançados por actions são enviados ao ErrorObserver, o “observador de erros”, que você pode definir durante a criação da store. Por exemplo:

var store = Store<AppState>(
initialState: AppState.estadoInicial(),
errorObserver: errorObserver,
);
bool errorObserver(Object error, ReduxAction action, Store store, Object state, int dispatchCount) {
print("Erro lançado por $action: $error);
return true;
}

Se o seu observador de erros devolver true, o erro vai ser relançado (rethrow) assim que o ErrorObserver terminar. Se ele devolverfalse, o erro é considerado como já processado, e vai ser “engolido” (não será feito rethrow).

Erros de Usuário

Para mostrar mensagens de erro ao usuário, basta suas actions lançarem erros do tipo UserException. Esta classe representa “erros de usuário” cujo objetivo é apenas dar avisos ao usuário, e não representa bugs no código. Daí, coloque sua home-page dentro do widget UserExceptionDialog, abaixo do StoreProvider e do MaterialApp:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context)
=> StoreProvider<AppState>(
store: store,
child: MaterialApp(
home: UserExceptionDialog<AppState>(
child: MyHomePage(),
)));
}

O código acima faz com que todas UserException sejam mostradas ao usuário numa janela modal (dialog). Veja o código: Exemplo de Mostrar Janela de Erro.


Testando

O vem com a classe StoreTester que facilita o teste de reducers, tanto síncronos quanto assíncronos. Comece criando o store-tester:

var storeTester =
StoreTester<AppState>(initialState: AppState.estadoIncial());

Então, despache alguma action, espere que ela termine e cheque o estado resultante:

storeTester.dispatch(SalvaNomeAction("Marcelo"));
TestInfo info = await storeTester.wait(SalvaNomeAction);
expect(info.state.nome, "Marcelo");

A variável info acima vai conter informações sobre que o reducer da action de rodar, não importa se o reducer é síncrono ou assíncrono.

Apesar do exemplo acima demonstrar o teste de uma action simples, no mundo real os aplicativos contém actions que disparam outras actions, síncronas ou assíncronas. Você pode usar os diferentes métodos do StoreTester para esperar qualquer quantidade de actions, checar a ordem delas, conferir o estado final após todas executarem, ou mesmo estados intermediários entre cada action. Por exemplo:

TestInfoList<AppState> infos = await storeTester.waitAll([
IncrementaEBuscaDescricaoAction,
EsperaAction,
IncrementaAction,
EsperaAction,
]);
var info = infos[IncrementaEBuscaDescricaoAction];
expect(info.state.espera, false);
expect(info.state.descricao, isNotEmpty);
expect(info.state.contador, 1);

Veja o código: Exemplo de Store Tester.


Navegação de Telas

O vem com a classe NavigateAction que você pode despachar para navegar no seu aplicativo Flutter:

dispatch(NavigateAction.pop());     
dispatch(NavigateAction.pushNamed("minhaRota"));
dispatch(NavigateAction.pushReplacementNamed("minhaRota"));
dispatch(NavigateAction.pushNamedAndRemoveAll("minhaRota"));
dispatch(NavigateAction.popUntil("minhaRota"));

Para que isso funcione, durante a inicialização do aplicativo você deve injetar a sua navigator-key na NavigateAction:

 navigatorKey = GlobalKey<NavigatorState>();

main() {
NavigateAction.setNavigatorKey(navigatorKey);
...
}

Veja o código: Exemplo de Navegação.


Eventos

Numa aplicação Flutter real não é prático assumir que o Redux sozinho pode conter todo o estado da aplicação. Widgets como o TextField e o ListView fazem uso de controllers que contém estado, e a store deve poder trabalhar junto com eles. Por exemplo, em resposta a uma action despachada você pode querer que um campo de texto seja limpo, ou então que uma lista seja movida para o seu topo.

O resolve esses problemas introduzindo o conceito de “eventos”:

var limpaTextoEvt = Event(); 
var mudaTextoEvt = Event<String>("Olá!");
var meuEvt = Event<int>(42);

Actions podem usar eventos como estado:

class LimpaTextoAction extends ReduxAction<AppState> {          
AppState reduce() => state.copy(limpaTextoEvt: Event());
}

E eventos podem ser passados peloStoreConnector para algum StatefulWidget, tal como qualquer outro estado:

class MyConnector extends StatelessWidget {          
Widget build(BuildContext context) {
return StoreConnector<AppState, ViewModel>(
model: ViewModel(),
builder: (BuildContext context, ViewModel vm) => MyWidget(
textoInicial: vm.textoInicial,
limpaTextoEvt: vm.limpaTextoEvt,
onLimpaTexto: vm.onLimpaTexto,
));
}
}

O seu widget vai “consumir” o evento no seu método didUpdateWidget e fazer algo com o valor contido no evento. Então, por exemplo, se você usar um controller para conter o texto de um TextField:

@override
void didUpdateWidget(MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
consomeEventos();
}
void consomeEventos() {
if (widget.limpaTextoEvt.consume())
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) controller.clear();
});
}

Veja o código: Exemplo de Eventos.


Terminamos nossa rápida olhada no . A documentação do pacote tem muito mais detalhes, e mostra várias outras funcionalidades não mencionadas aqui.

Este artigo tem uma versão em Inglês.

https://github.com/marcglasberg

https://twitter.com/GlasbergMarcelo

https://stackoverflow.com/users/3411681/marcg

Flutterando

Flutterando, a maior comunidade de Dart do Brasil!

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store