As aplicações reativas atuais são um absurdo completo
Já faz um tempo que venho pensando em falar sobre o quanto é complicado desenvolver aplicações reativas e hoje tive a coragem de começar a escrever sobre esse assunto tão delicado.
Desde 2013 trabalho com desenvolvimento de sistemas. Tenho desenvolvido principalmente aplicações web e apesar de bastante experiente, acho que ficou muito mais dificil construir aplicações com as novas tecnologias que surgiram na última década.
Quando comecei a desenvolver em 2013, bastava conhecer um pouco de orientação a objetos, PHP, MySQL, HTML, CSS e JQuery. Podiamos criar aplicações rapidamente, era muito produtivo.
Hoje em dia gastamos um tempo enorme lendo documentações, lidando com centenas de problemas criados muitas vezes por adortarmos excessivamente recursos como bibliotecas de terceiros, que depois de somadas, não nos torna mais produtivos.
Nesse post, vou focar num ponto que considero crucial para qualquer aplicação, o gerenciamento de estado.
Considero muito burocrático, complicado e as vezes até desnecessário em alguns casos mais graves. Adotar gerenciadores de estados de terceiros para a maioria dos projetos. Por isso, para não ficar apenas criticando, demonstro na prática como poderia ser uma solução eficaz diante das percepções que compartilho com você nesse momento.
Falando mal dos gerenciadores de estado atuais
Os principais gerenciadores de estado que conheço são:
Pensando no React ainda existem os hooks que "ajudam" o desenvolvedor a dizer ao React quando a aplicação deve reagir a mudanças no estado.
Sinceramente, do meu ponto de vista, todas essas soluções resolvem o problema do gerenciamento de estado, mas, implicando em complexidade extra e totalmente desnecessária.
Eu sinto que o Redux é extremamente verboso e percebi em alguns casos que ele complica muito o contexto das aplicações ao necessitar de vários arquivos para manter tudo organizado. Também acredito que o Vuex e o Pinia estão na mesma direção.
As vezes refletindo me pergunto o que pensavam essas pessoas quando criaram essas ferramentas. Será que pensavam "Por que facilitar se podemos dificultar!"?
Outro alvo das minhas críticas, o NgRx traz uma solução menos árdua, ainda acho complicado já que o RxJS tem um trilhão de métodos com comportamentos diferentes para resolver várias situações. Por isso, penso que tudo poderia ser muito mais simples do que é atualmente.
Bibliotecas de terceiros não deveriam ser o padrão
Me diga você, quem conhece melhor os problemas que o software que você está criando resolve?
Se sua resposta foi "Ninguém conhece melhor que meus clientes, minha equipe, minha empresa ou meus colaboradores", estou alinhado com você nessa forma de pensar. No entanto, isso me faz levantar outra questão "porque praticamente todo mundo usa bibliotecas de terceiros como um padrão para solucionar problemas de gerenciamento de estado?".
A pergunta feita no parágrafo anterior exige muito cuidado na formulação de uma resposta. Mas, sem pensar muito, uma resposta pode ser "não reinventar a roda" ou talvez "poupar tempo usando a sabedoria empregada nas bibliotecas que já estão validadas para uso".
Siceramente, não sinto que essas respostas são solidas o suficiente para justificar a adoção de bibliotecas de terceiros super complexas em detrimento de padrões simples que poderiam ser muito menos burocráticos.
Não me considero nem perto de ser o melhor desenvolvedor do mundo, mesmo assim, tenho construido software de boa qualidade ao longo da última década. Por isso, creio que desenvolver uma solução própria, baseada em patterns poderia ser melhor que usar bibliotecas de terceiros para esse fim.
Quem critica deve propor uma solução melhor
Sempre considerei que saber muito sobre algo é menos importante que conseguir fazer mais com o pouco que se sabe.
Me sinto muito alinhado com pensamentos que vão diretamente ao encontro de KISS, YAGNI e DRY,conceitos que me ajudam a manter as coisas simples.
A partir desse ponto, pretendo apresentar uma solução de gerenciamento de estado que considero eficaz, concisa e fácil de aprender a usar mesmo para desenvolvedores iniciantes.
Observer Pattern
O padrão observador é uma forma de fazer com que uma função seja executada no futuro, quando uma mudança ocorrer em um objeto observado.
Exemplo:
import { createState, render, html } from "./lib";
type TotalModel = {
total: number
}
type Params = {
state: TotalModel;
}
const template = ({state}: Params) => html`
<p><strong>Total</strong>: ${state.total}</p>
`
const AppTotal = () => {
const price = 20;
const operationTaxValue = 10;
const state = createState<TotalModel>({ total: 0 });
state.watchState( newState => render(template))
setTimeout(() => state.set({total: 5}), 3000)
const getTotal = () => {
state.setState({ total: price + operationTaxValue })
}
return { state, template }
}
Note no exemplo acima a importação dos serviços createState e render. O serviço createState é responsável por criar um objeto que informará ao render para atualizar a view novamente assim que o estado for atualizado.
Essas são operações que definem o comportamento do pardão observador e agora que a responsabilidade de cada serviço está clara, podemos seguir para a implementação de cada um deles.
Renderizador reativo
Um renderizador reativo é um serviço que pode construir uma árvore de elementos HTML e atualizar o DOM sempre que ocorrer alterações nos estados de um ou mais componentes.
Para começar, serão definidos os tipos usados para construir o renderizador reativo:
//lib/render/types.ts
import { TState } from "../state/types"
type Model = Object & {}
type TemplateParams<TModel> = {
state: TModel
}
export type Template<TModel> = {
(params: TemplateParams<TModel>): string
}
export type Component<TModel> = {
template: Template<TModel>,
state: TState<TModel>
}
export type ComponentFactory<TModel> = {
(): Component<TModel>
}
Cada um dos tipos criados, será utilizado para definir os parâmetros do renderizador que será construído logo adiante. Veja:
//lib/render/index.ts
import { ComponentFactory } from "./types";
const taggedFunction = (tags: any, ...values: any[]): string => {
return tags
.map((tag: string, index: number) => {
return `${tag}${values[index] || ""}`;
})
.join("");
};
export const html = taggedFunction;
export const css = taggedFunction;
const createSelector = (text: string) =>
text.split(/(?=[A-Z])/).join("-").toLowerCase();
const throwError = (message: string) => {
throw new Error(message);
}
const createComponentElement = (componentFactoryName: string): HTMLElement => {
if(!componentFactoryName) throwError('componentFactory name is not defined and must be.');
const selector = createSelector(componentFactoryName);
return document.createElement(selector);
}
export const render = <TModel>(
componentFactory: ComponentFactory<TModel>,
parentElement = document.body
): void => {
const componentElement = createComponentElement(componentFactory?.name) || null;
const component = componentFactory()
const state = component.state.state
!componentElement && throwError('Component element is not defined and must be.')
parentElement.innerHTML = '';
componentElement.insertAdjacentHTML('beforeend', component.template({ state }));
parentElement.insertAdjacentElement('beforeend', componentElement);
}
Observe que no arquivo render.ts declara 4 funções.
- createSelector: reponsável por criar o seletor do componente
- createComponentElement: responsável por criar o elemento wrapper do componente
- render: responsável por renderizar o componente
- taggedFunction: responsável pelo parse de tags e por ajudar com recursos de sintaxe HTML e CSS no VSCode
Das quatro funções citadas acima, a mais complexa é a função render, que deve receber outra função como componente e um elemento qualquer como contexto. Por padrão, essa função recebe como contexto o element body da página HTML.
Dentro da função render foram usadas outras duas funções, para criar os recursos dos quais depende a função render para injetar o componente na view através dos métodos insertAdjacentHTML e insertAdjacentElement.
Seria totamente possível usar apenas innerHTML, mas, as funções insertAdjacentHTML e insertAdjacentElement produzem o resultado esperado e ainda otimizam a performance das operações de manipulação do DOM.
Uma vez "pronto" o renderizador pode ser usado multiplas vezes, para tal, basta apenas importar o módulo.
import { render } from '@/services'
Gerenciador de estado
Seguindo em frente vou demonstrar como é possível estruturar um gerenciador de estado bem simples.
Em poucas linhas de código, será definido um gerenciador de estado bem completo. Que pode ser extendido para realizar todas as atividades que o padrão FLUX é capaz de operar, mas, como simplicidade muito mais efetiva.
Primeiro é necessário que os tipos que serão usados logo em seguida sejam definidos.
//lib/state/types.ts
export type TEmpty = null | undefined;
export type TGenericObject<T> = {
[key: string]: T;
};
export type TStateValue<T> = TGenericObject<T>;
export type TStateHandler<A> = <T extends A>(payload: T) => void;
export type TStateHandlerRemove = () => boolean;
export type TState<T> = {
state: T;
setState: (payload: T) => void;
watchState: (handler: TStateHandler<T>) => TStateHandlerRemove;
};
Os tipos acima podem parecer um pouco complexos, então, se você não estiver acostumado a trabalhar com Typescript, não se preocupe tanto. Vou tentar exclarecer alguns pontos abaixo para facilitar um pouco mais sua vida.
Nos trechos onde há algo como TStateValue<T>
& TStateHandler<A>
foram definidos tipos genéricos. Os tipos genéricos <T>
e <A>
são como os parâmetros de funções que permitem receber valores "dinamicamente".
No caso da tipagem, os tipos genéricos podem ser usados para tipar algum valor desconhecido no momento da declaração da tipagem. Dessa forma, o gerenciador de estado poderá tipar qualquer estrutura de dados no futuro.
Tomando como exemplo o código abaixo, você poderá perceber que o tipo TotalModel
foi passado como tipo genérico para a função createState. Esse método faz uso de tipos genéricos para definir o tipo de dados de um objeto externo.
O objeto
{total:0}
está sendo tipado através de um tipo genérico.
type TotalModel = {
total: number
}
const state = createState<TotalModel>({ total: 0 });
Observe a definição da fábrica de estados no código abaixo:
//lib/state/index.ts
import {
TState,
TStateHandler,
TStateHandlerRemove,
} from "./types";
export const createState = <T extends Object>(value: T): TState<T> => {
const state: T = value;
const handlers = new Set<TStateHandler<T>>();
const _notifyHandlers = (value: T): void => {
handlers.forEach((handler) => handler<T>(value));
};
const setState = (payload: T): void => {
const payloadCopy = JSON.parse(JSON.stringify(payload));
const stateCopy = JSON.parse(JSON.stringify(state));
const newState = { ...stateCopy, ...payloadCopy };
Object.assign(state, newState);
_notifyHandlers(newState);
};
const watchState = (handler: TStateHandler<T>): TStateHandlerRemove => {
handlers.add(handler);
return () => handlers.delete(handler);
};
return { state, setState, watchState };
};
A função createState declarada logo acima, aceita um tipo genérico derivado de Object e o usa para definir a estrutura de dados do objeto value e para definir o tipo de objeto a ser retornado por essa função.
O retorno da fábrica de estados acima, deve ser um objeto contento as funções setState, watchState e um objeto que represente o estado. Cada uma dessas funções responde por uma única responsabilidade, mas, juntas formam o Observer pattern.
O padrão observer pode ser usado para inscrever funções que devem ser executadas mediante alterações na estrutura de dados de um objeto, por isso, esse padrão pode ser muito útil para criar gerenciadores de estados.
Na solução que apresento, sempre que for necessário modificar o estado atual, a função setState deve ser usada e para os casos em que for necessário definir um observador para reagir a mudanças no estado, a função watchState é que deverá ser usada.
Veja abaixo:
const state = createState<TotalModel>({ total: 0 });
state.watchState( newState => console.log('Ocorreu alguma alteração no estado:', newState))
setTimeout(() => state.set({total: 5}), 3000)
No exemplo acima, um estado inicial foi definido como {total:0}
e depois de 3 segundos quando state.set
for executada, a função watchState será notificada e imediatamente executará a função de log exibindo a mensagem Ocorreu alguma alteração no estado e o Novo estado no console.
O comportamento descrito acima ocorre porque a função state.set
é um notificador e state.watchState
é responsável por inscrever observadores interesados em reagir a mudanças no estado.
Poderiamos incluir com muita facilidade outras funcões ao gerenciador de estado. Por exemplo, podriamos incluir uma função para desinscrever observadores, mas, creio que por hora esse exemplo basta.
Como os recursos criados devem ser usados na prática
Para fazer uso dos recursos criados anteriormente, não poderia ser mais simples. Basta seguir criando um componente e importando os módulos que foram criados para fabricar estados e definir quais observadores deverão reagir a alterações no estado.
Abaixo demonstro o código completo em um componente bem simples:
import { createState, render, html } from "./lib";
type TotalModel = {
total: number
}
type Params = {
state: TotalModel;
}
const template = ({state}: Params) => html`
<p><strong>Total</strong>: ${state.total}</p>
`
const AppTotal = () => {
const price = 20;
const operationTaxValue = 10;
const state = createState<TotalModel>({ total: 0 });
state.watchState( newState => render(template))
setTimeout(() => state.set({total: 5}), 3000)
const getTotal = () => {
state.setState({ total: price + operationTaxValue })
}
return { state, template }
}
No exemplo acima, após 3 segundos, o estado é alterado e o componente reage a mudança ocorrida renderizando o novo estado e o template do componente. Portanto, esse padrão tão simples, poderia ser extendido para gerenciar todo o estado de uma aplicação.
Como o código escrito está de acordo com a sugestão de YAGNI, DRY e KISS, manter e expandir suas capacidades não seria uma tarefa tão complexa. Além do mais, pelo menos algumas das bibliotecas que citei antes, já usam esse pattern sob o capô.
Criticando outras áreas do desenvolvimento de software
Nesse post falei apenas da ponta do Iceberg. A complexidade das aplicações que desenvolvemos hoje em dia são exageradamente grande e precisam ser eliminadas rapidamente do meu ponto de vista.
Apesar de discordar da forma como as coisas estão sendo feitas hoje em dia, não pense que sou da turma que chuta o pau da barraca e se torna radicalmente contra usar frameworks e bibliotecas.
Tenho usado algumas bibliotecas e frameworks de terceiros sempre que considero os ganhos que podem trazer como positivos ou muito positivos eu até criei minha própria biblioteca IARES para construir SPA.
Bem, por hora é só, mas, se você curtiu e quer acompanhar outras postagens, veja esses conteúdos que criei aqui, eles podem te trazer ótimos insights:
1 - Arquitetura para micro-frontend
2 - Metarepo vs Monorepo
3 - Definindo um bom codestyle