Extraindo lógica de componentes React

Componentes mais simples, cobertura de testes maior!

Image for post
Image for post
Já parou para pensar na quantidade de "ouro" dentro das funções dos seus componentes?

O ponto de partida

class Money extends Component {
static propTypes = {
currency: PropTypes.string.isRequired,
amount: PropTypes.number.isRequired,
}
getCurrencyData(currency) {
return {
GBP: { base: 100, symbol: '£' },
USD: { base: 100, symbol: '$' },
}[this.props.currency]
}
formatAmount(amount, base) {
return parseFloat(amount / base).toFixed(2)
}
render() {
const currency = this.getCurrencyData()
if (currency) {
const { symbol, base } = currency
const formatted = this.formatAmount(this.props.amount, base)
return (
<span>{symbol}{formatted}</span>
)
} else {
return <span>{this.props.amount}</span>
}
}
}

Existem duas funcionalidades, e são elas que vamos extrair em operações separadas:

  • getCurrencyData — busca informações da moeda que está sendo usada para formatar o conteúdo. Na realidade, essa operação seria mais complexa para suportar um maior número de idiomas. É um ótimo candidato para ir para um módulo separado.
  • formatAmount — recebe uma quantia e um valor base para produzir o resultado esperado. Claro, a lógica aqui é bem direta, até o momento, porém, quando nossa aplicação começar a suportar mais idiomas, você pode ver que essa operação ficará complexa.

A razão pela qual eu quero extrair isso é para testá-los em completo isolamento. Agora, para testar a formatação de quantidades, eu tenho que criar e montar um componente React, mas eu poderia apenas chamar essa função e verificar o resultado.

Extraindo a formatação de quantidade

export const formatAmount = (amount, base) => {
return parseFloat(amount / base).toFixed(2)
}

Agora, com esse nosso novo arquivo exportando a função, nós podemos substituir a operação no nosso componente Money importando o novo módulo:

import { formatAmount } from './format-currency'class Money extends Component {
...
formatAmount(amount, base) {
return formatAmount(amount, base)
}
...
}

Perceba que ainda temos a função formatAmount definida na classe Money: ao começar a extrair código de seus componentes, faça-o em pequenas partes, diminuindo assim, a chance de quebrar o seu código, e também, facilita o debug das partes que você mudou, caso algo der errado.

Como esses componentes estão bem testados, posso executar yarn test para garantir que todos os testes estão passando.

Agora, vamos remover a função formatAmount da classe Money e atualizar o render() para chamar nossa função externa:

// render do componente Money
render() {
const currency = this.getCurrencyData()
if (currency) {
const { symbol, base } = currency
// aqui estávamos usando `this.formatAmount`
const formatted = formatAmount(this.props.amount, base)

return (
<span>{symbol}{formatted}</span>
)
} else {
return <span>{this.props.amount}</span>
}
}

Mais uma vez, vamos rodar nossos testes com yarn test para confirma que tudo está bem. Agora, todos os nossos testes originais estão passando, podemos adicionar alguns testes novos para testar formatAmount isoladamente.

É importante ir etapa por etapa, dessa forma, mantendo todos os seus testes existentes em verde, antes de adicionar novos.

// testes para o nosso novo módulo
import { formatAmount } from './format-currency'
test('it formats the amount to 2 dp', () => {
expect(formatAmount(2000, 100)).toEqual('20.00')
})
test('respects the base', () => {
expect(formatAmount(2000, 10)).toEqual('200.00')
})
test('it deals with decimal places correctly', () => {
expect(formatAmount(2050, 100)).toEqual('20.50')
})

Agora, temos testes completos para formatar quantidades que não estão vinculadas ao nosso componente React. Claro, a função formatAmount é bem direta, por enquanto, mas, à medida que cresce, podemos testá-lo com muita facilidade sem necessidade de criar um componente React para isso.

Extraindo as informações de moeda

export const getCurrencyData = currency => {
return {
GBP: { base: 100, symbol: '£' },
USD: { base: 100, symbol: '$' },
}[this.props.currency]
}

Opa! Peraê! Temos um erro!… A função recebe um argumento, que é a moeda desejada, mas na verdade, a função o ignora completamente em favor de this.props.currency.

Isso é totalmente acidental, mas mostra o valor de separar a lógica de negócios da lógica dos nossos componentes de interface.

Em um componente React, é muito fácil se referir a this.props ou this.state e, fica difícil, rastrear quais funções usam quais valores.

Ao extrair isso em seus próprios módulos, obriga você a passar argumentos, o que, de fato, te ajuda a esclarecer a API e a pensar sobre quais dados a função realmente precisa.

Depois de corrigir esse erro, garantimos a chamada de getCurrencyData com o valor certo e atualizamos a função para se referir ao argumento passado, e não a this.props.currency, podemos fazer a função na classe Money delegar para a nova função:

...
import { getCurrencyData } from './currency-data'
class Money extends Component {
...
getCurrencyData(currency) {
return getCurrencyData(currency)
}
render() {
const currency = this.getCurrencyData(this.props.currency)
...
}
}

E, mais uma vez, rodamos yarn test para confirmar que nada quebrou. Agora, podemos fazer o próximo passo, que é excluir completamente a função getCurrencyData da nossa classe Money e simplesmente chamar a função externa no render():

render() {
const currency = getCurrencyData(this.props.currency)
...
}

Agora, vamos escrever alguns testes para getCurrencyData():

import { getCurrencyData } from './currency-data'test('for GBP it returns the right data', () => {
expect(getCurrencyData('GBP')).toEqual({
base: 100,
symbol: '£',
})
})

Para esse artigo — e também devido aos dados que estão sendo simplificados — vou manter apenas esses testes para esta função, mas em uma situação mais complexa, escreveríamos um conjunto completo de testes conforme necessário.

Simplificando o componente Money

import React, { Component } from 'react';
import PropTypes from 'prop-types'
import { formatAmount } from './format-currency'
import { getCurrencyData } from './currency-data'
class Money extends Component {
static propTypes = {
currency: PropTypes.string.isRequired,
amount: PropTypes.number.isRequired,
}
render() {
const currency = getCurrencyData(this.props.currency)
if (currency) {
const { symbol, base } = currency
const formatted = formatAmount(this.props.amount, base)
return (
<span>{symbol}{formatted}</span>
)
} else {
return <span>{this.props.amount}</span>
}
}
}
export default Money

O componente Money, agora, tem apenas um único método implementado, o render(). Essa é uma ótima chance de mover o componente Money para um componente funcional, sem estado (FSC — Functional Stateless Component).

Podemos reescrever Money dessa maneira:

import React from 'react';
import PropTypes from 'prop-types'
import { formatAmount } from './format-currency'
import { getCurrencyData } from './currency-data'
const Money = ({ currency, amount }) => {
const currencyData = getCurrencyData(currency)
if (currencyData) {
const { symbol, base } = currencyData
const formatted = formatAmount(amount, base)
return (
<span>{symbol}{formatted}</span>
)
} else {
return <span>{amount}</span>
}
}Money.propTypes = {
currency: PropTypes.string.isRequired,
amount: PropTypes.number.isRequired,
}
export default Money

Eu sou um grande fã de FSCs, eles encorajam componentes a serem simples e a termos uma separação da lógica na nossa UI, e não é coincidência que, ao fazermos essa refatoração hoje, percebemos que nosso componente Money pode ser escrito dessa maneira.

Conclusão

Eu incentivo você a pensar duas vezes sobre a adição de métodos arbitrários aos componentes React, é muito fácil se referir a this.props.x.

Ao extrair funções para seus próprios módulos, você é obrigado a considerar quais props são necessárias e como sua função funcionará. Isso torna o código mais claro, é mais fácil ver quais props são usadas e onde, significando que sua lógica de negócios fica mais complexa, você pode testá-las sem ter que criar seus componentes React.

Se você quiser brincar com o código, você pode acessar o repositório no GitHub. Não hesite em criar uma issue se tiver alguma dúvida ou para discutir um ponto de vista!

Créditos

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store