Máquinas de estado finito com @wordpress/data e TypeScript

Publicados: 2021-09-16

Suponha que queremos projetar uma tela de login em nosso aplicativo. Quando o usuário o vê pela primeira vez, há um formulário vazio. O usuário preenche os campos e, uma vez configurados, ele pressiona o botão de login para validar as credenciais e, bem, efetuar o login. Se a validação for bem-sucedida, ele passa para a próxima tela. Mas se isso não acontecer, eles recebem uma mensagem de erro e são solicitados a tentar novamente.

Se você implementasse o estado de uma tela assim e lesse meus posts sobre como definir o estado de um aplicativo com TypeScript e @wordpress/data , aposto que faria algo assim:

 type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };

que tem todos os campos necessários para administrar o estado, mas… isso é o melhor que podemos fazer?

Máquinas de estado finito

Uma máquina de estados finitos (FSM) é, em linhas gerais, um modelo matemático que nos permite definir um conjunto finito de estados e as possíveis transições entre eles. (mais informações na Wikipédia). Eu gosto dessa abstração porque ela se ajusta perfeitamente às nossas necessidades quando se trata de modelar o estado de nossos aplicativos. Por exemplo, o diagrama de estado para nossa tela de login pode ser algo assim:

Diagrama de estado de um formulário de login
Diagrama de estado de um formulário de login.

que, como você pode ver, captura de forma clara e concisa o comportamento que queremos implementar.

Definindo Estados com @wordpress/data

Em nossa série introdutória ao React, vimos como usar o pacote @wordpress/data para criar e gerenciar o estado do nosso aplicativo. Fizemos isso definindo os principais componentes do nosso armazenamento de dados:

  • Um conjunto de seletores para consultar o estado
  • Um conjunto de ações para acionar uma solicitação de atualização
  • Uma função redutora para, dado o estado atual e uma ação de atualização, atualizar o estado

Em essência, as lojas em @wordpress/data já se comportam como máquinas de estados finitos, já que a função redutora “transiciona” de um estado para outro usando uma ação:

 ( state: State, action: Action ) => State

Como definir uma máquina de estado finito com TypeScript em @wordpress/data

Parece que @wordpress/data stores estão bem próximos do modelo FSM que apresentamos algumas linhas acima: estamos apenas perdendo os estados específicos em que podemos estar e as transições entre eles… Então, vamos dar uma olhada em como podemos corrigir esses problemas, um passo de cada vez.

Estados explícitos

Iniciamos este post mostrando uma possível implementação do estado do nosso aplicativo. Nossa primeira proposta era um monte de atributos que faziam o trabalho, mas não parecia uma máquina de estado finita. Então, vamos começar certificando-se de que nossos estados sejam definidos explicitamente em nosso modelo:

 type State = | Form | LoggingIn | Success | Error; type Form = { readonly status: 'form'; readonly username: string; readonly password: string; }; type LoggingIn = { readonly status: 'logging-in'; readonly username: string; readonly password: string; }; type Success = { readonly status: 'success'; // ... }; type Error = { readonly status: 'error'; readonly message: string; };

Bem óbvio, hein? Há um tipo por estado e o estado geral do aplicativo é definido como uma união discriminada de tipos. É assim chamado porque (a) o estado é “a união de diferentes tipos” (ou seja, é Form , ou é LoggingIn , ou seja o que for ) e (b) temos um atributo (neste caso , status ) que nos permite discriminar qual estado específico temos em cada momento.

Acho que esta solução é muito melhor do que a que tínhamos no início. Em nossa solução original, conseguimos definir “estados inválidos”, porque alguém poderia estar logando (apenas definir isLoggingIn como true ) e, ao mesmo tempo, estar em um estado de erro (apenas definir errorMessage com um valor diferente do valor vazio corda). Mas isso claramente não faz sentido! Qual é? Estamos fazendo login ou devemos mostrar um erro?

A nova solução, por outro lado, é muito mais precisa quando se trata de representar o estado: torna os estados “inválidos” “impossíveis”. Se estivermos em LoggingIn , não há como configurar uma mensagem de erro (não há atributo para isso!). Se estivermos mostrando um Error , você não poderá fazer login. Muito melhor, não acha?

Ações

Em @wordpress/data , as ações atualizam a loja em nosso estado. Como estamos modelando nosso estado como um FSM, nossas ações serão as setas do nosso diagrama original. Tudo o que você precisa fazer é modelá-los como uma união discriminada de tipos (por convenção, o discriminador geralmente é denominado type ) e pronto:

 type SetCredentials = { readonly type: 'SET_CREDENTIALS'; readonly username: string; readonly password: string; }; type Login = { readonly type: 'LOGIN'; }; type ShowApp = { readonly type: 'SHOW_APP'; // ... }; type ShowError = { readonly type: 'SHOW_ERROR'; readonly message: string; }; type BackToLoginForm = { readonly type: 'BACK_TO_LOGIN_FORM'; };

Nossas ações devem representar as setas que tínhamos em nosso diagrama original, certo? Bem, eu não sei você, mas eu acho que esses tipos de ação não parecem flechas! As setas têm uma direção (vão de um estado para outro); ações não.

Precisamos de uma maneira de deixar claro em nosso código que certas ações são úteis apenas para passar de um estado para outro. E até agora a melhor solução que encontrei é usar o próprio redutor:

 function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } case ...: ... } return state; }

Se nosso redutor começar filtrando o status de nosso state , saberemos a “fonte” de nossa seta. Então, olhamos para a ação atual e, se for uma “seta para fora”, geramos o novo estado “alvo”.

Você pode ver claramente como isso funciona no snippet acima. Se o status atual for form , a ação SET_CREDENTIALS nos leva ao mesmo estado em que estávamos (atualizando as credenciais, duh) e a ação LOGIN altera o estado para logging-in . Qualquer outra ação neste estado é ignorada e, portanto, o estado permanece inalterado.

Está tudo bem e perfeito, mas… não sei, não gosto muito do código, e você? Quero ter certeza de que meu código é claro, conciso, autoexplicativo e seguro. Podemos fazer isso?

Redutor fortemente tipado

Para corrigir a confusão em que acabamos de entrar, só precisamos refatorar nosso reducer para que cada estado possível em nosso FSM tenha seu próprio redutor. Se fizermos isso corretamente, eles serão fortemente tipados e ficará claro o que é permitido e o que não é.

Vamos começar criando um tipo para cada estado em nosso aplicativo. Cada tipo agrupará as ações que podem nos levar de um determinado estado para outro lugar. Por exemplo, nosso estado Form tem duas setas para fora ( SetCredentials e Login ), então vamos criar o tipo FormAction com a união das duas ações. Repita o processo para cada estado e você obterá isso:

 type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;

Em seguida, defina todos os redutores que precisamos. Novamente, um para cada estado:

 function reduceForm( state: Form, action: FormAction ): Form | LoggingIn { switch ( action.type ) { switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } } reduceLoggingIn: ( state: LoggingIn, action: LoggingInAction ) => Success | Error reduceError: ( state: Error, action: ErrorAction ) => Form reduceSuccess: ( state: Success, action: SuccessAction ) => ...

Graças ao TypeScript e aos tipos que acabamos de definir, agora podemos ser extremamente precisos sobre o estado de origem e o conjunto de ações que cada redutor espera, bem como os estados de destino que podemos obter como resultado.

Finalmente, simplesmente precisamos amarrar tudo com uma função reducer exclusiva:

 function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': return reduceForm( state, action as FormActions ) ?? state; case 'logging-in': return reduceLoggingIn( state, action as LoggingInActions ) ?? state; case 'error': return reduceError( state, action as ErrorActions ) ?? state; case 'success': return reduceSuccess( state, action as SuccessActions ) ?? state; } }

O código resultante é, na minha opinião, mais claro e fácil de manter. E, além disso, o TypeScript agora pode verificar o que estamos fazendo e detectar quaisquer cenários ausentes. Impressionante!

Conclusão

Neste post vimos o que é uma máquina de estado finito e como podemos implementar uma em um @wordpress/data store com TypeScript. Nossa solução final é bastante simples e, no entanto, oferece muitas vantagens: captura melhor o estado e a intenção do nosso aplicativo e aproveita o poder do TypeScript.

Espero que tenham gostado deste post! Se você gostou, compartilhe com seus amigos e colegas. E, por favor, deixe-me saber como você lida com esses problemas na seção de comentários abaixo.

Imagem em destaque por Patrick Hendry no Unsplash.