Uma introdução a MobX
Entendendo os principais conceitos dessa biblioteca de gerenciamento de estado para web apps
O MobX é uma biblioteca de gerenciamento de estado para Javascript frequentemente utilizada com React. Ela tem uma curva de aprendizado menos abrupta do que a sua alternativa mais popular, o Redux, apesar de (de acordo com minha experiência na Cubos) más práticas emergirem com mais frequência com seu uso. Nesse artigo, usaremos alguns exemplos de código em Typescript para explicar os principais conceitos, boas práticas e alguns gotchas.
O que é MobX
O MobX aplica conceitos de programação funcional reativa em Javascript de forma transparente, escondendo detalhes complexos de implementação por trás de objetos Javascript puros. Tirando o jargão, isso significa que ele permite que partes de sua aplicação sejam notificadas de mudanças de estado sem mudar o paradigma de programação do seu projeto. Para determinar o que é observado e por quem, a biblioteca se utiliza de dois conceitos de base: observáveis e reações.
Observáveis e reações
Observáveis são porções de estado (variáveis, listas, objetos, campos de uma classe, etc) que notificam ouvintes (as reações) quando há mudanças. Observáveis são, em geral, definidos usando a função observable
, exportada pelo módulo mobx
. Há várias maneiras de declarar reações, uma delas sendo usar a função autorun
. Segue exemplo:
import { observable, autorun } from "mobx";
const lista = observable([1, 2, 3]);
autorun(() => {
const soma = lista.reduce((acc, el) => acc + el, 0);
console.log(soma);
});
// printa 6
lista.push(4);
// printa 10
Eis o que a biblioteca faz, por debaixo dos panos: roda a função passada para autorun
uma vez, registra todos os observáveis utilizados dentro da função e a executa todas as vezes que algum desses observáveis mudar. O uso acima mostrado de observáveis é, apesar de possível, raro na prática. Com frequência, usamos classes que contém campos observáveis. Segue um exemplo mais próximo do uso prático:
class TaskStore {
@observable
public tasks: Task[];
constructor() {
try {
this.tasks = JSON.parse(localStorage.getItem("tasks")) || [];
} catch {
this.tasks = [];
}
autorun(() => localStorage.setItem("tasks", JSON.stringify(this.tasks)));
}
}
O exemplo acima ilustra um dos usos mais frequentes de autorun
na prática: persistir estado. Além disso, usa-se observable
como um decorador, o que só é possível dentro de classes.
Observáveis: valores a que reações podem se inscrever para ser notificadas de mudanças
Reações: ações a serem executadas sempre que determinados observáveis mudarem
Valores derivados
Até agora, nós discutimos observáveis e reações. No entanto, reagir a mudanças no estado nem sempre é suficiente: por vezes, precisamos usar uma parte do estado para calcular outra. Por exemplo:
import { observable } from "mobx";
interface ExamResult {
student: string;
grade: number;
}
class ExamStore {
@observable
public results: ExamResult[] = [];
public averageGrade() {
return this.results.reduce((acc, el) => acc + el, 0) / this.results.length;
}
}
const store = new ExamStore();
store.results.push({ student: "Victor", grade: 7 });
store.results.push({ student: "Clara", grade: 8 });
store.results.push({ student: "Dygufa", grade: 9 });
console.log(store.averageGrade());
document.querySelector("avg").innerText = `Média da turma: ${store.averageGrade()}`;
Nesse exemplo, averageGrade
é chamado duas vezes, recalculando a média desnecessariamente. Isso poderia ser otimizado, já que enquanto results
não mudar, a média permanecerá a mesma (para quem é familiar com Redux, nesse contexto se usariam seletores). E é justamente para isso que serve o computed
, mais uma função da biblioteca. O exemplo acima ficaria assim, quando reescrito para utilizar computed
:
import { observable, computed } from "mobx";
interface ExamResult {
student: string;
grade: number;
}
class ExamStore {
@observable
public results: ExamResult[] = [];
@computed
public get averageGrade() {
return this.results.reduce((acc, el) => acc + el, 0) / this.results.length;
}
}
const store = new ExamStore();
store.results.push({ student: "Victor", grade: 7 });
store.results.push({ student: "Clara", grade: 8 });
store.results.push({ student: "Dygufa", grade: 9 });
console.log(store.averageGrade);
document.querySelector("avg").innerText = `Média da turma: ${store.averageGrade}`;
As mudanças foram:
- O decorador
@computed
foi adicionado ao métodoaverageGrade
- O método foi convertido em getter - isso é um requisito para usar
computed
- Por ser um getter,
averageGrade
passa a ser utilizado como um campo, não um método (e por isso não é mais chamado)
Com essas 3 mudanças, o MobX otimiza o cálculo da média, fazendo-o apenas quando necessário. Além disso, averageGrade
também é um observável, o que permite que reações se inscrevam a ele e reajam a mudanças.
Valores derivados: observáveis derivados a partir de outros observáveis que são automaticamente re-calculados sempre que os observáveis de que depende mudam
Dica: sempre que possível, use
computed
para derivar informações a partir do estado: isso evita que computações custosas se repitam desnecessariamente
Observadores
Antes de introduzir outros conceitos de MobX, gostaria de tornar nossos exemplos mais práticos, usando o MobX em uma aplicação React:
Observação: O exemplo acima é editável, dá pra abrir ele no CodeSandbox, fazer mudanças e ver o resultado em tempo real (dá pra experimentar colocar um console.log
no computed
todoCount
, por exemplo, pra verificar se ele de fato só é chamado quando há mudanças)
Nessa aplicação, usamos todos os conceitos que já vimos: observáveis, valores derivados e reações. Usamos também uma biblioteca nova, a mobx-react
: nela são expostas funções que facilitam a integração de mobx
, que pode ser usado com qualquer biblioteca de UI, com React. Uma dessas funções é a observer
, cuja função é criar uma reação que assiste às mudanças em observáveis utilizados dentro de render e, a cada mudança, re-renderizam o componente. Conforme visto no exemplo, é possível usar observer
tanto como decorador de classe (como em App
, no exemplo) quanto como higher-order function (com componentes funcionais, como em Task
).
observer
: função que torna a re-renderização de um componente uma reação às mudanças de estado
Ações
Conforme citado anteriormente, o MobX é extremamente flexível - e a subsequente falta de padrões e boas práticas pode ter consequências negativas. O fato de que o estado é mutável leva com frequência desenvolvedores a mudá-lo diretamente nos componentes que o utilizam, espalhando a lógica de negócio por inúmeros arquivos, gerando dívida técnica e dificultando a manutenção. Outro anti-pattern comum é assumir que se está recebendo um observable
como propriedade e modificá-lo diretamente, possivelmente introduzindo bugs sutis e difíceis de identificar caso a propriedade não seja observável.
Pensando nisso, o mobx
introduz o conceito de action
: funções que modificam o estado. Seu uso traz alguns benefícios:
- Reações só são notificadas de mudanças de estado no fim da action, prevenindo a criação de vários passos intermediários e atualização desnecessária da UI em cada etapa
- Melhor integração com as ferramentas de debugging do MobX
- Se seu uso for difundido, é fácil saber onde mudanças de estado ocorrem
A seguir, o exemplo anterior, reescrito para usar actions:
Como se pode ver, action
pode ser usado como decorador e como higher-order function, assim como observer
. Outra novidade é o configure({ enforceActions: true })
: essa configuração faz o MobX disparar um erro sempre que um estado for mudado de fora de uma action
.
Dica: experimente tirar um
@action
e utilizar a UI para ver o erro sendo disparado
Uma ressalva: action
não funciona bem com funções assíncronas, devido à forma como elas são transpiladas para ES5:
configure({ enforceActions: true });
class AsyncStore {
@observable
public isLoading = false;
@action
public async loadStuff = () => {
this.isLoading = true;
// funciona normalmente
await makeRequest();
this.isLoading = false;
// dispara erro acusando mudança de estado fora de actions
})
}
A solução oficial para esse problema é uma nova função chamada flow
, sobre a qual se pode ler na documentação. Infelizmente, ela não funciona bem com Typescript e, por isso, não é usada na Cubos.
Ação: Toda função que modifica estado é uma ação. Preferencialmente, todas elas devem ser marcadas como ações usando a função
action
Modo estrito: Modo em que mudanças de estado fora de uma action disparam erro (
configure({ enforceActions: true })
)
Gotcha: Ações assíncronas exigem adaptações para não disparar erros no modo estrito após o uso de
await
. Recomenda-se evitar o uso ou substituiraction
porflow
Estado global
No começo desse artigo, o MobX foi citado como alternativa para o Redux. No entanto, até agora, só usamos o MobX para gerenciar estado local, enquanto seu concorrente é comumente utilizado para gerenciar o estado global da aplicação. Para fazer o mesmo com a biblioteca em questão, usaremos a função inject
e o componente Provider
(similares ao connect
e ao Provider
do react-redux
, respectivamente), ambos de mobx-react
. Segue exemplo:
O Provider é um componente que recebe um objeto cujas chaves são os nomes das stores e cujos valores são as stores (exemplo: { taskStore: new TaskStore() }
, onde ‘taskStore’ é o nome da store new TaskStore()
). Ele deve estar sempre no topo da hierarquia, acima de qualquer componente que possa usar o estado global. O Provider disponibiliza as stores para todos os componentes abaixo dele através de contexto.
Já o inject
é uma função que recebe uma quantidade qualquer de nomes de store e é responsável por se comunicar com o contexto e disponibilizar as stores como propriedades para o componente em que ela é aplicada.
Dica: o
inject
sempre deve vir antes doobserver
Essa arquitetura favorece boas práticas, separando lógica de negócio de lógica de UI, e é uma excelente opção para projetos maiores, em que um estado é utilizado em pontos muito diferentes da aplicação.
Algumas coisas mais
toJS
Em geral, o comportamento de valores observáveis é idêntico ao comportamento padrão. Porém, há exceções (exemplo: Array e ObservableArray tem comportamentos diferentes em alguns casos) e, caso se encontre bugs obscuros ou se esteja passando observáveis para bibliotecas de terceiros, é recomendável usar a função toJS
de mobx
para se obter um valor não alterado pelo MobX.
Mapas observáveis
Somente as propriedades já presentes em um objeto no momento de sua instanciação serão observáveis:
class BuggyStore {
@observable statusByClient: { [clientId: string]: string } = { nicolas: "Pending" };
autorun(() => console.log("statusByClient foi modificado", this.statusByClient));
}
const store = new BuggyStore();
store.statusByClient.nicolas = "Done";
// o campo 'nicolas' é observável, então autorun roda e a mensagem é printada
store.statusByClient.victor = "Done";
// o campo 'victor' não é observável, então autorun não roda e a mensagem não é printada
Nesse caso, recomenda-se utilizar mapas observáveis:
import { observable, autorun } from "mobx";
class BuggyStore {
@observable statusByClient: Map<string, string> = new Map([["nicolas", "Pending"]]);
autorun(() => console.log("statusByClient foi modificado", this.statusByClient));
}
const store = new BuggyStore();
store.statusByClient.set("nicolas", "Done");
store.statusByClient.set("victor", "Done");
// o autorun roda nos dois casos! :)
Prós e contras
Porque usar MobX
Principais prós:
- Boilerplate mínimo ou ausente
- Re-renderização é automaticamente otimizada de forma granular, só sendo causada por mudanças de observáveis recentemente utilizados no
render
de cada componente - Tendo sido feita em Typescript, ela trabalha muito bem com tipos (algo muito difícil de fazer e custoso de manter com Redux)
Principais contras:
- Boa parte da funcionalidade da biblioteca é abstraída e invisível para o desenvolvedor (vulgo mágica), o que pode tornar debugar difícil
- Flexível demais: boas práticas precisam ser ativamente estimuladas pela equipe de desenvolvimento (enquanto em Redux as boas práticas são forçadas pela arquitetura)
Porque não usamos Redux
Os principais problemas que encontramos com Redux foram:
- Muito, muito boilerplate
- Tipar o payload recebido por reducers das actions era extremamente custoso e difícil de manter (específico para quem usa Typescript, Flow ou similares)
- Seletores são uma adição, ao invés de estar no cerne do paradigma
Conclusão
Como vimos, MobX é uma poderosa e biblioteca simples de usar. Com poucas linhas de código e sem introduzir grandes mudanças no estilo ou paradigma de programação, seu projeto pode fazer uso de um gerenciamento de estado simples e ter otimizações de performance automáticas, sem esforço.