Do caos ao controle: interfaces reativas com JS puro
Manter a interface sincronizada com os dados manualmente é um dos maiores desafios no desenvolvimento front-end. Quando o valor de uma variável muda, todos os elementos do DOM que dependem dela precisam ser atualizados individualmente. Para resolver isso, vamos ver neste artigo como podemos construir uma estrutura de Estado Reativo, sem precisar importar bibliotecas inteiras apenas para isso.
O Núcleo de Dados (ReactiveState)
A primeira coisa que vamos fazer é criar um local centralizado para os dados. Ele é o estado atual da aplicação. Ele vai ser nosso “Single Source of Truth” e vai carregar todas as propriedades e registrar suas mudanças. Além disso, para ser realmente reativo, vamos usar os eventos do JavaScript para notificar a aplicação toda quando o estado for modificado. (Mais tarde vamos ver o que fazemos com essa informação.)
Vamos ter um construtor para iniciar nosso estado com valores padrão. O método set é o coração da lib: ele valida se a chave existe, verifica se o valor realmente mudou (para evitar processamento desnecessário) e dispara o aviso para o restante da aplicação. Temos também outros métodos como get e create para inicializar uma nova propriedade no estado.
class ReactiveState extends EventTarget {
constructor(initialState) {
super();
// Criamos cópias profundas para evitar mutação por referência
this.data = this.deepClone(initialState);
this.initial = this.deepClone(initialState);
}
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// Registra formalmente uma nova chave no estado
create(key, value) {
this.data[key] = value;
this.initial[key] = value;
}
get(key) {
return this.data[key];
}
set(key, value) {
// Trava de segurança: impede criação acidental de chaves
if (!(key in this.data)) {
throw new Error(`Key "${key}" does not exist. Use create().`);
}
const prev = this.data[key];
if (value === prev) return; // Trava de igualdade
this.data[key] = value;
// Notifica o sistema sobre a mudança
this.dispatchEvent(new CustomEvent('change', {
detail: {
key,
value,
prev,
timestamp: Date.now(),
isReset: false
}
}));
}
}
Persistência e Reset
Para tornar a biblioteca robusta, adicionamos a capacidade de “voltar no tempo” (Reset) e exportar os dados (Snapshot). O reset utiliza a cópia initial que criamos no construtor.
// Adicione estes métodos à classe ReactiveState:
reset(key = null) {
if (key !== null) {
const prev = this.data[key];
this.data[key] = this.initial[key];
this.dispatchEvent(new CustomEvent('change', {
detail: { key, value: this.data[key], prev, isReset: true }
}));
} else {
const prevData = { ...this.data };
this.data = this.deepClone(this.initial);
for (const k in this.data) {
this.dispatchEvent(new CustomEvent('change', {
detail: { key: k, value: this.data[k], prev: prevData[k], isReset: true }
}));
}
}
}
// Usamos base64 para serializar e desserializar nossos dados
snapshot() {
return btoa(JSON.stringify(this.data));
}
load(base64) {
const newData = JSON.parse(atob(base64));
const prevData = { ...this.data };
this.data = newData;
for (const key in newData) {
this.dispatchEvent(new CustomEvent('change', {
detail: { key, value: newData[key], prev: prevData[key], isReset: false }
}));
}
}
O Consumidor de Reações (Effect)
Lembra que falamos que emitimos eventos quando mudamos o estado? Pois bem, a classe Effect serve para organizar como a interface reage às mudanças. O seu construtor recebe uma instância do ReactiveState e pode disparar funções específicas para cada propriedade do estado. O método dispose serve basicamente para interromper a escuta dos eventos. Ele é fundamental para limpar a memória quando a reação não for mais necessária. Isso é para impedir, por exemplo, o estado tentar modificar algum elemento que já não existe mais.
class Effect {
constructor(reactiveState) {
this.state = reactiveState;
this.reactions = new Map();
// Mantemos a referência do bind para remoção posterior
this.changeHandler = this.handleChange.bind(this);
this.state.addEventListener('change', this.changeHandler);
}
on(key, callback) {
this.reactions.set(key, callback);
}
handleChange(event) {
const { key } = event.detail;
const callback = this.reactions.get(key);
if (callback) {
callback(event.detail);
}
}
dispose() {
this.state.removeEventListener('change', this.changeHandler);
this.reactions.clear();
}
}
Demonstração Prática
Com a biblioteca pronta em Vanilla JS, podemos utilizá-la em qualquer projeto. No exemplo abaixo, usamos jQuery apenas para simplificar a manipulação do DOM e focar na lógica reativa.
<div class="dashboard">
<p>Volume: <span id="vol-txt"></span></p>
<button id="up">+</button>
<button id="down">-</button>
<button id="reset">Resetar</button>
</div>
<script>
// 1. Instância única do estado
const appState = new ReactiveState({ volume: 50 });
// 2. Criação do observador
const ui = new Effect(appState);
// 3. Definição da reação (A UI reage ao dado)
ui.on('volume', ({ value }) => {
$('#vol-txt').text(value);
$('#vol-txt').css('color', value > 80 ? 'red' : 'black');
});
// 4. Gatilhos (Interações mudam apenas o dado)
$('#up').click(() => appState.set('volume', appState.get('volume') + 5));
$('#down').click(() => appState.set('volume', appState.get('volume') - 5));
$('#reset').click(() => appState.reset());
// Inicialização da tela
$('#vol-txt').text(appState.get('volume'));
</script>
Nesta arquitetura, os botões não “enxergam” que existe um campo de texto ou uma regra de estilo. Eles apenas atualizam o estado. A responsabilidade de atualizar a tela fica inteiramente com o Effect, garantindo que o fluxo de dados seja unidirecional e previsível.
Demonstração Interativa
Veja o exemplo funcionando na prática:
See the Pen ReactiveState by filipemtx (@filipemtx) on CodePen.
E aí, o que você achou dessa ideia? Compartilha com seus amigos para mostrar que Vanilla JS e design patterns resolvem muitos problemas sem exigir um monte de banda de internet.