Programação Funcional em JS, Imutabilidade e Reducers com Immer
Renato Rodrigues
Posted on November 11, 2021
Programação Funcional
Resumidamente podemos dizer que as linguagens que trabalham com o paradigma de programação funcional seguem alguns preceitos básicos:
- Funções puras: Recebem as informações que precisam por parâmetros e retornam sempre o mesmo resultado se chamadas com os mesmos parâmetros;
- Sem efeitos colaterais: As funções não mutam informações fora de seus escopos;
- Stateless: As funções não possuem e não dependem de estados internos ou externos;
- Idempotentes: As funções podem ser chamadas um número infinito de vezes e em diferentes contexto que resultado vai ser sempre o mesmo se os argumentos forem os mesmos, não gerando side-effects nem persistindo nenhuma alteração.
Programação Funcional em Javascript
Javascript por padrão não é uma linguagem funcional, porém por ser uma linguagem altamente flexível, adaptável e independente de paradigmas, os padrões de Programação Funcional podem ser utilizados em JS. É importante observar no entanto, que por ser uma linguagem extremamente permissiva e não te obrigar a praticamente nada, o Javascript não vai garantir que seu código siga todos os preceitos da PF. Fica a cargo do desenvolvedor aplicar esses preceitos corretamente.
Função não pura e com side effect em JS
var externalCount = 0;
function plus1() {
externalCount = externalCount + 1;
return externalCount;
}
console.log('External count:', externalCount); // External count: 0
var internalCount = plus1();
console.log('Internal count:', internalCount); // Internal count: 1
console.log('External count:', externalCount); // External count: 1
Programação Funcional em JS antes do ES5
Antes do ES5 (ECMAScript 5th Edition) os conceitos de Programação Funcional praticamente não existiam em JS e só poderiam ser obtidos replicando manualmente esses comportamentos em seu código ou utilizando libs externas que faziam exatamente isso (ex, Underscore.js).
Programação Funcional em JS com ES5 (~ 2012*)
O ES5 trouxe para o mundo do JS alguns conceitos e funcionalidades de Programação Funcional, como por exemplo as funções map, filter e reduce, possibilitando aplicar esse novo paradigma onde antes não era possível. As revisões futuras do ES vieram a reforçar esse suporte.
* O ES5 foi oficialmente criado em 2009, mas só começou mesmo a ser utilizado por volta de 2012 devido à incompatibilidade com a base instalada de navegadores antigos.
Filtrando antes do ES5
var allNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var evenNumbers = [];
for(var i = 0; i < allNumbers.length; i++) {
if(allNumbers[i] % 2 === 0) {
evenNumbers.push(allNumbers[i]);
}
}
// evenNumbers = 0, 2, 4, 6, 8
Filtrando com o ES5
var allNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var evenNumbers = allNumbers.filter(function(number) {
return number % 2 === 0
});
// evenNumbers = 0, 2, 4, 6, 8
Imutabilidade
Imutabilidade em seu conceito mais básico diz que uma informação após ser definida não pode ser mais alterada.
Em programação isso traz algumas vantagens, como por exemplo garantir a consistência da informação durante todo o fluxo de dados, não gerar efeitos colaterais em funções, poder fazer referências à informação original ao invés de copiá-la, poder usar essa informação como índice ou chave, passá-la pra múltiplos threads e funções assíncronas, etc.
Imutabilidade em JS
Valor x referência
Em Javascript a maioria dos parâmetros são passados por referência, ou seja, ao invés de serem copiados, uma referência à informações original será criada. Isso significa que qualquer alteração feita nesse parâmetro refletirá também na informação original em si e vice-versa.
Tipos primitivos
A exceção à essa regra são os tipos primitivos da linguagem Javascript, que sempre são passados como valor, sem nenhuma referência à informação original. São eles:
Boolean
, Number
, String
, Null
, Undefined
e Symbol
.
Tipos Object
Tudo que não for um tipo primitivo em JS é do tipo Object
e esse tipo sempre é passado por referência, portanto NÃO é imutável.
Tipos primitivos não mutam
var externalCount = 0;
function plus1(count) {
count = count + 1;
return count;
}
console.log('External count:', externalCount); // External count: 0
var internalCount = plus1(externalCount);
console.log('Internal count:', internalCount); // Internal count: 1
console.log('External count:', externalCount); // External count: 0
Já os tipos não primitivos (Object) mutam
var externalCount = {
value: 0,
};
function plus1(count) {
count.value = count.value + 1;
return count;
}
console.log('External count:', externalCount.value); // External count: 0
var internalCount = plus1(externalCount);
console.log('Internal count:', internalCount.value); // Internal count: 1
console.log('External count:', externalCount.value); // External count: 1
Então como obter imutabilidade em JS?
Pré ES5
Até o advento do ES5 era praticamente inexistente o conceito de imutabilidade em JS, pelo menos de forma nativa, tendo que recorrer a libs externas que de alguma maneira tentavam emular esse comportamento.
ES5
Object.assign
O ES5 introduziu o método assign
aos objetos JS, que permite copiar o conteúdo de um número N de objetos à direita ao objeto mais à esquerda, mutando o mesmo e retornando o resultado. Note que somente o objeto mais à esquerda é mutado, os demais não.
Object.assign mutando o objeto mais à esquerda
var nameOnly = {
name: 'Renato',
};
var lastNameOnly = {
lastName: 'Rodrigues',
}
var emailOnly = {
email: 'renato.rodrigues@renatorodrigues.com',
}
var myInfo = Object.assign(nameOnly, lastNameOnly, emailOnly);
// nameOnly = { name: 'Renato', lastName: 'Rodrigues', email: 'renato.rodrigues@renatorodrigues.com' }
// myInfo = { name: 'Renato', lastName: 'Rodrigues', email: 'renato.rodrigues@renatorodrigues.com' }
Tá, mas e a imutabilidade? Lembrando que quando falamos de JS, a linguagem nos permite fazer praticamente qualquer coisa, temos então um pulo do gato aí. Em nenhum lugar é dito que o objeto mais à esquerda precisa ser uma referência a um objeto previamente criado. Ele pode ser simplesmente um objeto vazio, recém criado, que vai receber todos os valores à direita. Logo, se não existe uma referência pra ele, não existe o que mutar. ¯\(ツ)/¯
Object.assign NÃO mutando o objeto mais à esquerda
var myInfo = Object.assign({}, nameOnly, lastNameOnly, emailOnly);
// nameOnly = { name: 'Renato' }
// myInfo = { name: 'Renato', lastName: 'Rodrigues', email: 'renato.rodrigues@renatorodrigues.com' }
Importante: O Object.assign
somente copia as propriedades de primeiro nível dos objetos em si, caso uma propriedade seja um objeto nested ou faça uma referência a um objeto externo elas poderão ser mutadas, pois continuarão a apontar para o objeto original.
ES6 (2015)
O ES6 (ECMAScript 6th Edition, a.k.a ES2015) trouxe mais algumas funções e funcionalidades relacionadas à imutabilidade, apesar de *spoiler* nenhuma delas garantir uma imutabilidade 100%.
Const
O ES6 trouxe ao JS o conceito de constantes
, que em teoria são variáveis imutáveis, mas não se deixe enganar, o const
só funciona assim com tipos primitivos. Com tipos Object ele não permite que você reatribua um novo valor à variável, mas continua permitindo que as propriedades de um objeto já atribuído a ela sejam alteradas.
Imutabilidade com Const
const name = 'Renato';
const person = {
name: 'Renato',
}
name = 'Roberto'; // Error: Assignment to constant variable.
person = { name: 'Roberto' }; // Error: Assignment to constant variable.
person.name = 'Roberto' // person = { name: 'Roberto' }
Object.seal
O Object.seal
"sela" um objeto, não permitindo que nenhuma propriedade seja removida e nenhuma nova seja adicionada a ele, porém permite que suas propriedades já existentes sejam modificadas.
Importante: nenhum erro ou warning é retornado quando se tenta mutar um objeto selado.
const myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
};
Object.seal(myInfo);
delete myInfo.email;
myInfo.age = 90;
myInfo.name = "Roberto";
Resultado quando não selado
myInfo = {
name: 'Roberto',
lastName: 'Rodrigues',
age: 90,
}
Resultado quando selado
myInfo = {
name: 'Roberto',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
}
Object.freeze
O Object.freeze
"congela" um objeto, não permitindo que nenhuma propriedade seja removida, nenhuma nova seja adicionada a ele e nem que suas propriedades já existentes sejam modificadas.
Importante: nenhum erro ou warning é retornado quando se tenta mutar um objeto congelado.
const myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
};
Object.freeze(myInfo);
delete myInfo.email;
myInfo.age = 90;
myInfo.name = "Roberto";
Resultado quando congelado
const myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
}
"Ah mas então isso é imutabilidade!"
Assim como no Object.assign
(e no Object.seal
), o Object.freeze
também só vale para as propriedades de primeiro nível do objeto em si, caso uma propriedade seja um objeto nested ou faça uma referência a um objeto externo seus filhos poderão ser mutados, pois esse objeto não estará congelado.
Objetos congelados com propriedades nested
const myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
job: {
title: "Self Employed",
email: "renato@renatorodrigues.com",
}
};
const jobAtSpaceX = {
title: "VP of Mars Colonization",
email: "renato.rodrigues@wearecoming.mars",
};
Object.freeze(myInfo);
myInfo.job = jobAtSpaceX;
myInfo.job.wage = undefined;
Resultado obtido
myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
job: {
title: 'Self Employed',
email: 'renato.sp@gmail.com',
wage: undefined
}
}
Objetos congelados com referência externa
const jobAtSpaceX = {
title: "VP of Mars Colonization",
email: "renato.rodrigues@wearecoming.mars",
};
const myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
job: jobAtSpaceX,
};
Object.freeze(myInfo);
jobAtSpaceX.wage = Infinity;
Resultado obtido
myInfo = {
name: 'Renato',
lastName: 'Rodrigues',
email: 'renato.rodrigues@renatorodrigues.com',
job: {
title: 'VP of Mars Colonization',
email: 'renato.rodrigues@wearecoming.mars',
wage: Infinity,
}
}
ES6 (para Arrays) e E2018 (para Objetos)
Spread operator
O Spread operador se tornou o meio padrão de conseguir imutabilidade em JS, pois ao "espalhar" as propriedades de um array
(ES6) ou objeto
(ES2018) em um novo array ou objeto, uma cópia é feita, se tornando assim independente do original.
Mergeando objetos com o Spread
var nameOnly = {
name: 'Renato',
};
var lastNameOnly = {
lastName: 'Rodrigues',
}
var emailOnly = {
email: 'renato.rodrigues@renatorodrigues.com',
}
var myInfo = { ...nameOnly, ...lastNameOnly, ...emailOnly };
// nameOnly = { name: 'Renato' }
// myInfo = { name: 'Renato', lastName: 'Rodrigues', email: 'renato.rodrigues@renatorodrigues.com' }
"Ah, mas então está aí a imutabilidade 100% em JS"
O Spread Operator é como se fosse uma "versão moderna" do Object.assign({}, N)
. A mesma situação com propriedades nested e referências externas citadas anteriormente também ocorre com o spread.
Proxies
Uma das novidades mais interessantes (e menos utilizadas) do ES6 foram os proxies. Podemos dizer que quando se faz proxy de um objeto, ele fica "protegido" por um proxy que tem acesso à leitura e à gravação de suas propriedades e pode manipular como fazê-los. Resumindo, o proxy "esconde" o objeto original e qualquer acesso à ele é feito via métodos especiais chamados de traps. Isso pode ser utilizado juntamente com um padrão que favoreça a imutabilidade do objeto original, como por exemplo o copy-on-write.
Funcionamento prático do Proxy
Exemplo Proxy
const personObj = {
firstName: 'Renato',
lastName: 'Rodrigues',
}
const handler = {
set(target, property, value) { // set Trap
if (Array.isArray(value)) {
target[property] = value.join(', ');
} else {
target[property] = value;
}
},
get(target, property) { // get Trap
if (typeof target[property] === 'string' && target[property].match(/, /)) {
return target[property].split(', ');
} else {
return target[property];
}
}
}
const personProxy = new Proxy(personObj, handler);
personObj.hobbies = "video games, running";
personProxy.pets = ["Mario", "Luigi"];
// personObj.hobbies = "video games, running"
// personObj.pets = "Mario, Luigi"
// personProxy.hobbies = ["video games", "running"]
// personProxy.pets = ["Mario", "Luigi"]
Immer
Fazendo o uso massivo de proxies, a biblioteca Immer surgiu como uma maneira mais simples e direta de garantir a imutabilidade de objetos em JS. Basicamente o que ela faz é proteger o objeto original (olha o Proxy aí \o/) e persistir cada alteração feita nele num objeto intermediário, chamado de Draft, para depois no final da operação produzir um novo objeto com o resultado das alterações do draft aplicadas ao objeto original.
Funcionamento prático da Immer
Mas como a Immer funciona?
Exemplo sem Immer (muta o objeto original)
const person = {
firstName: 'Renato',
lastName: 'Rodrigues',
}
const withCobli = (collaborator) => {
collaborator.company = 'Cobli';
return collaborator;
}
const employee = withCobli(person);
// person: { firstName: 'Renato', lastName: 'Rodrigues', company: 'Cobli' }
// employee: { firstName: 'Renato', lastName: 'Rodrigues', company: 'Cobli' }
Exemplo com Spread (não muta o objeto original)
const person = {
firstName: 'Renato',
lastName: 'Rodrigues',
}
const withCobli = (collaborator) => {
return {
...collaborator,
company: 'Cobli',
};
];
const employee = withCobli(person);
// person: { firstName: 'Renato', lastName: 'Rodrigues' }
// employee: { firstName: 'Renato', lastName: 'Rodrigues', company: 'Cobli' }
Exemplo com Immer
const person = {
firstName: 'Renato',
lastName: 'Rodrigues',
}
const withCobli = produce(collaborator, draftCollaborator => {
draftCollaborator.company = 'Cobli';
});
const employee = withCobli(person);
// person: { firstName: 'Renato', lastName: 'Rodrigues' }
// employee: { firstName: 'Renato', lastName: 'Rodrigues', company: 'Cobli' }
Porque Immer?
De acordo com o artigo de introdução à Immer, somente na listagem do Redux-ecosystem-links existem outras 67 bibliotecas de imutabilidade em JS. Então porque usar a Immer?
- Pequena: Somente 3kb e com ZERO dependências;
- Rápida: Utiliza funções nativas de JS, como os proxies, ao invés de loops e condicionais;
- Simples: Pode ser implementada em uma linhas e não requer nenhuma configuração;
- Versátil: Suporta propriedades nested de infinitos níveis e mantem a tipagem correta das propriedades!
- Fácil: Curva de aprendizagem praticamente zero, somente sintaxe nativa de JS, nenhuma outra sintaxe ou padrão é necessário;
- Transparente: Uma vez implementada, você mal percebe que está usando;
- Estruturalmente Equivalente: Partes não mutadas do novo objeto vão continuar fazendo referência ao objeto original. Mantendo eles assim equivalentes e evitando a duplicação de informações idênticas na memória.
Reducers
No mundo React existe um fluxo obrigatório de como as informações devem ser persistidas e lidas na Store (state). Esse fluxo é uni-direcional, com passos bem definidos e sempre trabalhando com imutabilidade (viu a chance aí?). Ele se aplica tanto ao estado da aplicação (usando Redux), quanto ao estado interno dos componentes (usando useReducer)
Um reducer nada mais é que uma função que recebe dois parâmetros, o estado atual e uma ação que contém um tipo e pode ou não conter um payload. A partir do tipo da ação é decidido quais mudanças serão aplicadas ao estado da aplicação/componente. Como este estado é imutável, esta função deve retornar uma nova cópia dele já com as diferenças aplicadas. Este então será o novo estado.
Hoje em dia, grande maioria dos devs utiliza o spread operator para fazer essa cópia do estado atual e modificá-lo sem mutá-lo
Reducer usando spread
const demoReducer = (state: demoState, action: demoActions): demoState => {
switch (action.type) {
case ActionTypes.INCREMENT_COUNTER:
return {
...state,
count: state.count + 1
};
case ActionTypes.DECREMENT_COUNTER:
return {
...state,
decrementCount: state.decrementCount - 1
};
case ActionTypes.SET_AMOUNT:
return {
...state,
amount: action.payload
};
default:
return state;
}
};
Reducer usando spread (erro)
const demoReducer = (state: demoState, action: demoActions): demoState => {
switch (action.type) {
case ActionTypes.INCREMENT_COUNTER:
return {
...state,
count: state.count++
};
}
};
Currying
Currying é o processo de transformar uma função que espera vários argumentos em uma função que espera um único argumento e retorna outra função que recebe um argumento e assim sucessivamente até o final dos argumentos. Por exemplo, uma função que recebe três argumentos, vai resultar em uma função que recebe um argumento e retorna uma função que recebe um argumento, que por sua vez retorna uma função que recebe um argumento e retorna o resultado da função original.
As funções curried podem tanto receber parâmetros das funções acima quanto os valores retidos nessas funções (closures)
Exemplo Currying
function sayTo(who) {
const person = who.toString().toUpperCase();
return function say(what) {
console.log(`Say ${what} to ${person}`)
}
}
const sayToRenato = sayTo('Renato');
sayToRenato('Wasuuuup!');
// Say Wasuuuup! to RENATO
const sayToCaio = sayTo('Caio');
sayToCaio('Yo Bro!')
sayToCaio('Whats going on?');
// Say Yo Bro! to CAIO
// Say Whats going on? to CAIO
Curried Reducer
Aproveitando as vantagens do currying, os desenvolvedores da Immer criaram o modo curried do método produce
, que cai como uma luva quando usado em um Reducer. Ele recebe como o único parâmetro uma função de callback que vai receber um state e uma action. O curried reducer irá retornar uma função que ao receber o state original irá criar um draft dele e chamará o callback passando esse draft junto com a action passada pelo Redux/useReducer, criando assim um reducer 100% compatível e completamente transparente para quem o chama.
Exemplo Curried Reducer
import produce, { Draft } from "immer";
const demoReducer = produce((draft: Draft<DemoState>, action: DemoActions) => {
switch (action.type) {
case ActionTypes.INCREMENT_COUNTER:
draft.count++;
break;
case ActionTypes.DECREMENT_COUNTER:
draft.decrementCount--;
break;
case ActionTypes.SET_AMOUNT:
draft.amount = action.payload;
break;
}
});
(Sim, é só isso que precisa pra usar, não tem mais nada mesmo)
Como migrar para Immer
1) Importe a função produce
da Immer;
2) Adicione-a no modo curried ao seu reducer ja existente;
3) Remova todos os returns;
4) Remova todos os spreads;
5) Mute as propriedades diretamente;
6) Remova o case default.
Desafios
1) Esquecer o padrão spread;
2) (Re-)aprender a mutar propriedades de um objeto usando JS básico.
Na dúvida sempre lembre-se
Bonus: Mutações básicas
// Alterar propriedade
draft.property = newValue;
// Criar propriedade
draft.newProperty = value;
// Excluir propriedade
delete draft.uselessProperty;
// Alterar/criar propriedade nested
draft.nestedObject.nestedProperty = value;
// Excluir propriedade nested
delete draft.nestedObject.uselessNestedProperty;
// Alterar/criar propriedade nested em um array
draft.nestedArray[i].nestedProperty = value;
// Adicionar um item ao um array
draft.nestedArray.push(newObj);
// Excluir propriedade nested em um array
delete draft.nestedArray[i].uselessNestedProperty;
// Excluir um item ao um array
draft.nestedArray.splice(i, 1);
Referência completa de mutação em objetos e arrays
FAQ
Preciso migrar tudo?
Nop. A menos que queira.
Qual o melhor momento para migrar então?
Considerando que a migração é bem simples, eu usaria a seguinte regra: Tem que modificar algo num Reducer? já aproveita a chance e migra.
Ideias para o futuro
- Adicionar Reducers com Immer ao gerador de pacotes. (done)
- <insira sua ideia aqui>
Exemplos de reducers migrados
- Sandbox Demo: Antes, Depois
- Reducer de EventStatus[] SSE
- Reducer de Checklists
Apêndice 1: Igualdade de valores e referências
Igualdade de valores e referências
var allNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var evenNumbers = [];
for (var i = 0; i < allNumbers.length; i++) {
if (allNumbers[i] % 2 === 0) {
evenNumbers.push(allNumbers[i]);
}
}
evenNumbers == allNumbers; // false
evenNumbers[0] === allNumbers[0]; // true
evenNumbers[1] === allNumbers[2]; // true
Igualdade de valores e referências usando filter
var allNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var evenNumbers = allNumbers.filter(function (number) {
return number % 2 === 0
});
evenNumbers == allNumbers; // false
evenNumbers[0] === allNumbers[0]; // true
evenNumbers[1] === allNumbers[2]; // true
Igualdade de valores e referências em objetos
var allNumbers = [
{ v : 0 }, { v : 1 }, { v : 2 }, { v : 3 }, { v : 4 },
{ v : 5 }, { v : 6 }, { v : 7 }, { v : 8 }, { v : 9 }
];
var evenNumbers = allNumbers.filter(function (item) {
return item.v % 2 === 0
});
evenNumbers == allNumbers; // false
evenNumbers[0] === allNumbers[0]; // true
evenNumbers[0].v === allNumbers[0].v; // true
evenNumbers[1] === allNumbers[2]; // true
evenNumbers[1].v === allNumbers[2].v; // true
Igualdade de valores e referências com Object.assign
var nameOnly = {
name: 'Renato',
};
var lastNameOnly = {
lastName: 'Rodrigues',
}
var emailOnly = {
email: 'renato.rodrigues@renatorodrigues.com',
}
var myInfo = Object.assign(nameOnly, lastNameOnly, emailOnly);
myInfo.name === nameOnly.name; // true
myInfo.email === nameOnly.email; // true
myInfo === nameOnly; // true
Igualdade de valores e referências com Object.assign({})
var nameOnly = {
name: 'Renato',
};
var lastNameOnly = {
lastName: 'Rodrigues',
}
var emailOnly = {
email: 'renato.rodrigues@renatorodrigues.com',
}
var myInfo = Object.assign({}, nameOnly, lastNameOnly, emailOnly);
myInfo.name === nameOnly.name; // true
myInfo.email === nameOnly.email; // false
myInfo === nameOnly; // false
Igualdade de valores e referências com o spread operator
var nameOnly = {
name: 'Renato',
};
var lastNameOnly = {
lastName: 'Rodrigues',
}
var emailOnly = {
email: 'renato.rodrigues@renatorodrigues.com',
}
var myInfo = { ...nameOnly, ...lastNameOnly, ...emailOnly };
myInfo.name === nameOnly.name; // true
myInfo.email === nameOnly.email; // false
myInfo === nameOnly; // false
Equality Operator (==) e Strict Equality Operador (===)
Para tipos primitivos
== Compara igualdade de valores (faz conversões)
=== Compara igualdade de valores e tipo (não faz conversões)
"1" == 1; // true
"1" === 1; // false
1 == true); // true
1 === true); // false
"" == false); // true
"" === false); // false
[] == false); // true
[] === false); // false
undefined == null); // true
undefined === null); // false
"" == undefined); // false
"" === undefined); // false
Para tipos Object
== Compara referência (ignora estrutura e valores)
=== Compara referência (ignora estrutura e valores)
var person = {
name: 'Renato',
}
var person2 = {
name: 'Renato',
}
var person3 = person;
person == person2; // false
person === person2; // false
person == person3; // true
person === person3; // true
Bonus: Igualdade de NaN
const a = NaN;
const b = NaN;
a == NaN; // false
b == NaN; // false
a == b; // false
NaN == NaN; // false
Object.is(a, b); // true
Object.is(NaN, NaN); // true
Posted on November 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024