Melhores redutores com imersão
Publicados: 2022-03-10Como desenvolvedor React, você já deve estar familiarizado com o princípio de que o estado não deve ser modificado diretamente. Você pode estar se perguntando o que isso significa (a maioria de nós teve essa confusão quando começamos).
Este tutorial fará jus a isso: você entenderá o que é estado imutável e a necessidade disso. Você também aprenderá como usar o Immer para trabalhar com estado imutável e os benefícios de usá-lo. Você pode encontrar o código neste artigo neste repositório do Github.
Imutabilidade em JavaScript e por que isso importa
Immer.js é uma pequena biblioteca JavaScript escrita por Michel Weststrate cuja missão declarada é permitir que você “trabalhe com estado imutável de uma maneira mais conveniente”.
Mas antes de mergulhar no Immer, vamos relembrar rapidamente a imutabilidade em JavaScript e por que isso é importante em um aplicativo React.
O mais recente padrão ECMAScript (também conhecido como JavaScript) define nove tipos de dados integrados. Destes nove tipos, há seis que são referidos como valores/tipos primitive
. Esses seis primitivos são undefined
, number
, string
, boolean
, bigint
e symbol
. Uma simples verificação com o operador typeof
do JavaScript revelará os tipos desses tipos de dados.
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
Uma primitive
é um valor que não é um objeto e não possui métodos. O mais importante para nossa discussão atual é o fato de que o valor de uma primitiva não pode ser alterado depois de criada. Assim, as primitivas são ditas immutable
.
Os três tipos restantes são null
, object
e function
. Também podemos verificar seus tipos usando o operador typeof
.
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
Esses tipos são mutable
. Isso significa que seus valores podem ser alterados a qualquer momento após serem criados.
Você pode estar se perguntando por que eu tenho o array [0, 1]
lá em cima. Bem, em JavaScriptland, um array é simplesmente um tipo especial de objeto. Caso você também esteja se perguntando sobre null
e como ele é diferente de undefined
. undefined
significa simplesmente que não definimos um valor para uma variável enquanto null
é um caso especial para objetos. Se você sabe que algo deve ser um objeto, mas o objeto não está lá, você simplesmente retorna null
.
Para ilustrar com um exemplo simples, tente executar o código abaixo no console do seu navegador.
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
deve retornar uma matriz, que é um tipo de object
. Quando não consegue encontrar tal objeto, ele retorna null
. Retornar undefined
também não faria sentido aqui.
Chega disso. Vamos voltar a discutir a imutabilidade.
De acordo com os documentos do MDN:
“Todos os tipos, exceto objetos, definem valores imutáveis (ou seja, valores que não podem ser alterados).”
Essa instrução inclui funções porque elas são um tipo especial de objeto JavaScript. Veja a definição da função aqui.
Vamos dar uma olhada rápida no que os tipos de dados mutáveis e imutáveis significam na prática. Tente executar o código abaixo no console do seu navegador.
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
Nossos resultados mostram que mesmo que b
seja “derivado” de a
, alterar o valor de b
não afeta o valor de a
. Isso decorre do fato de que quando o mecanismo JavaScript executa a instrução b = a
, ele cria um novo local de memória separado, coloca 5
nele e aponta b
nesse local.
E os objetos? Considere o código abaixo.
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
Podemos ver que alterar a propriedade name via variável d
também a altera em c
. Isso decorre do fato de que quando o mecanismo JavaScript executa a instrução c = { name: 'some name
'
}
, o mecanismo JavaScript cria um espaço na memória, coloca o objeto dentro dele e aponta c
para ele. Então, quando ele executa a instrução d = c
, o mecanismo JavaScript apenas aponta d
para o mesmo local. Ele não cria um novo local de memória. Assim, qualquer alteração nos itens em d
é implicitamente uma operação nos itens em c
. Sem muito esforço, podemos ver por que isso é um problema em formação.
Imagine que você esteja desenvolvendo um aplicativo React e em algum lugar você deseja mostrar o nome do usuário como some name
lendo a variável c
. Mas em algum outro lugar você introduziu um bug em seu código manipulando o objeto d
. Isso faria com que o nome do usuário aparecesse como new name
. Se c
e d
fossem primitivos não teríamos esse problema. Mas as primitivas são muito simples para os tipos de estado que uma aplicação React típica precisa manter.
Trata-se das principais razões pelas quais é importante manter um estado imutável em seu aplicativo. Recomendo que você verifique algumas outras considerações lendo esta breve seção do README do Immutable.js: o caso da imutabilidade.
Tendo entendido por que precisamos de imutabilidade em um aplicativo React, vamos agora dar uma olhada em como o Immer aborda o problema com sua função de produce
.
Função de produce
do Immer
A API principal do Immer é muito pequena, e a função principal com a qual você trabalhará é a função de produce
. produce
simplesmente recebe um estado inicial e um retorno de chamada que define como o estado deve ser alterado. O próprio retorno de chamada recebe uma cópia de rascunho (idêntica, mas ainda assim uma cópia) do estado para o qual faz toda a atualização pretendida. Finalmente, ele produce
um novo estado imutável com todas as alterações aplicadas.
O padrão geral para esse tipo de atualização de estado é:
// produce signature produce(state, callback) => nextState
Vamos ver como isso funciona na prática.
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
No código acima, simplesmente passamos o estado inicial e um retorno de chamada que especifica como queremos que as mutações aconteçam. É simples assim. Não precisamos tocar em nenhuma outra parte do estado. Ele deixa initState
intocado e compartilha estruturalmente as partes do estado que não tocamos entre os estados inicial e os novos. Uma dessas partes em nosso estado é a matriz de pets
de estimação. O nextState
produce
uma árvore de estado imutável que contém as alterações que fizemos, bem como as partes que não modificamos.
Armado com esse conhecimento simples, mas útil, vamos dar uma olhada em como o produce
pode nos ajudar a simplificar nossos redutores React.
Redutores de escrita com imersão
Suponha que temos o objeto de estado definido abaixo
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
E queríamos adicionar um novo objeto e, em uma etapa subsequente, definir sua chave installed
como true
const newPackage = { name: 'immer', installed: false };
Se fôssemos fazer isso da maneira usual com a sintaxe de propagação de array e objeto JavaScripts, nosso redutor de estado poderia se parecer com o abaixo.
const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };
Podemos ver que isso é desnecessariamente detalhado e propenso a erros para esse objeto de estado relativamente simples. Também temos que tocar em todas as partes do estado, o que é desnecessário. Vamos ver como podemos simplificar isso com Immer.
const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
E com algumas linhas de código, simplificamos bastante nosso redutor. Além disso, se cairmos no caso padrão, o Immer apenas retornará o estado de rascunho sem que precisemos fazer nada. Observe como há menos código clichê e a eliminação do espalhamento de estado. Com o Immer, nos preocupamos apenas com a parte do estado que queremos atualizar. Se não encontrarmos tal item, como na ação `UPDATE_INSTALLED`, simplesmente seguimos em frente sem tocar em mais nada. A função `produce` também se presta ao curry. Passar um callback como o primeiro argumento para `produce` deve ser usado para curry. A assinatura do "produto" curry é //curried produce signature produce(callback) => (state) => nextState
Vamos ver como podemos atualizar nosso estado anterior com um produto curry. Nosso produto curry ficaria assim: const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
A função de produtos com curry aceita uma função como seu primeiro argumento e retorna um produto com curry que só agora requer um estado a partir do qual produzir o próximo estado. O primeiro argumento da função é o estado de rascunho (que será derivado do estado a ser passado ao chamar este produto curry). Em seguida, segue cada número de argumentos que desejamos passar para a função.
Tudo o que precisamos fazer agora para usar esta função é passar o estado do qual queremos produzir o próximo estado e o objeto de ação assim.
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
Observe que em um aplicativo React ao usar o gancho useReducer
, não precisamos passar o estado explicitamente como fiz acima porque ele cuida disso.
Você pode estar se perguntando, Immer estaria recebendo um hook
, como tudo em React hoje em dia? Bem, você está na companhia de boas notícias. Immer tem dois ganchos para trabalhar com estado: os ganchos useImmer
e useImmerReducer
. Vamos ver como eles funcionam.
Usando os ganchos useImmer
e useImmerReducer
A melhor descrição do hook useImmer
vem do próprio README do use-immer.
useImmer(initialState)
é muito semelhante auseState
. A função retorna uma tupla, o primeiro valor da tupla é o estado atual, o segundo é a função de atualização, que aceita uma função de produtor immer, na qual odraft
pode ser modificado livremente, até que o produtor termine e as alterações sejam feitas imutável e se tornar o próximo estado.
Para fazer uso desses ganchos, você deve instalá-los separadamente, além da biblioteca principal do Immer.
yarn add immer use-immer
Em termos de código, o gancho useImmer
se parece com abaixo
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
E é tão simples quanto isso. Você poderia dizer que é o useState do React, mas com um pouco de esteróide. Para usar a função de atualização é muito simples. Ele recebe o estado de rascunho e você pode modificá-lo o quanto quiser, como abaixo.
// make changes to data updateData(draft => { // modify the draft as much as you want. })
O criador do Immer forneceu um exemplo de codeandbox com o qual você pode brincar para ver como funciona.
useImmerReducer
é igualmente simples de usar se você usou o hook useReducer
do React. Tem uma assinatura semelhante. Vamos ver como isso se parece em termos de código.
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
Podemos ver que o redutor recebe um estado draft
que podemos modificar o quanto quisermos. Há também um exemplo de codeandbox aqui para você experimentar.
E é assim que é simples usar ganchos Immer. Mas caso você ainda esteja se perguntando por que deve usar o Immer em seu projeto, aqui está um resumo de algumas das razões mais importantes que encontrei para usar o Immer.
Por que você deve usar o Immer
Se você escreveu lógica de gerenciamento de estado por qualquer período de tempo, apreciará rapidamente a simplicidade que o Immer oferece. Mas esse não é o único benefício que a Immer oferece.
Quando você usa o Immer, acaba escrevendo menos código clichê, como vimos com redutores relativamente simples. Isso também torna as atualizações profundas relativamente fáceis.
Com bibliotecas como Immutable.js, você precisa aprender uma nova API para aproveitar os benefícios da imutabilidade. Mas com o Immer você consegue a mesma coisa com Objects
, Arrays
, Sets
e Maps
JavaScript normais. Não há nada de novo para aprender.
O Immer também fornece compartilhamento estrutural por padrão. Isso significa simplesmente que, quando você faz alterações em um objeto de estado, o Immer compartilha automaticamente as partes inalteradas do estado entre o novo estado e o estado anterior.
Com o Immer, você também obtém o congelamento automático de objetos, o que significa que você não pode fazer alterações no estado produced
. Por exemplo, quando comecei a usar o Immer, tentei aplicar o método sort
em uma matriz de objetos retornados pela função de produção do Immer. Ele lançou um erro me dizendo que não posso fazer nenhuma alteração na matriz. Eu tive que aplicar o método array slice antes de aplicar sort
. Mais uma vez, o nextState
produzido é uma árvore de estado imutável.
O Immer também é fortemente tipado e muito pequeno, com apenas 3 KB quando compactado com gzip.
Conclusão
Quando se trata de gerenciar atualizações de estado, usar o Immer é um acéfalo para mim. É uma biblioteca muito leve que permite que você continue usando todas as coisas que aprendeu sobre JavaScript sem tentar aprender algo totalmente novo. Eu encorajo você a instalá-lo em seu projeto e começar a usá-lo imediatamente. Você pode adicioná-lo em projetos existentes e atualizar incrementalmente seus redutores.
Eu também encorajo você a ler o post introdutório do blog Immer por Michael Weststrate. A parte que acho especialmente interessante é o “Como funciona o Immer?” seção que explica como o Immer aproveita recursos de linguagem como proxies e conceitos como copy-on-write.
Eu também encorajo você a dar uma olhada neste post do blog: Imutabilidade em JavaScript: Uma Visão Contratiana onde o autor, Steven de Salas, apresenta seus pensamentos sobre os méritos de buscar a imutabilidade.
Espero que com as coisas que você aprendeu neste post você possa começar a usar o Immer imediatamente.
Recursos Relacionados
-
use-immer
, GitHub - Immer, GitHub
-
function
, documentos da web MDN, Mozilla -
proxy
, documentos da web MDN, Mozilla - Objeto (ciência da computação), Wikipedia
- “Imutabilidade em JS”, Orji Chidi Matthew, GitHub
- “Tipos de dados e valores ECMAScript”, Ecma International
- Coleções imutáveis para JavaScript, Immutable.js , GitHub
- “O caso da imutabilidade”, Immutable.js , GitHub