Uma metodologia para desenvolvimento de projetos com React, Redux, GraphQL, ImmutableJS, reselect, recompose, etc...
Uma metodologia que estou desenvolvendo com base em aprendizados nos diversos projetos que realizei.
As estratégias seguidas neste modelo também são fruto de muitas pesquisas e se baseiam no uso de bibliotecas modernas, seguindo todas as melhores práticas de desenvolvimento web moderno.
Os passos a seguir fazem parte de um ciclo de desenvolvimento e podem ser aplicados de uma vez para o projeto todo ou subdivindo o projeto em pequenas entregas (iterações)
Seguiremos uma série de passos tendo como objetivo o desenvolvimento de uma determinada funcionalidade para nosso software. A imagem a seguir mostra uma visão macro do processo. Nela, assumimos que a equipe está utilizando o SCRUM como framework de desenvolvimento de projeto.
Esta metodologia poderia ser aplicada em outros modelos, desde que tenhamos de antemão uma lista de tarefas/funcionalidades a serem desenvolvidas nesta iteração.
Tendo em mãos os wireframes/mockups que contenham todos os elementos (e a representação de suas funcionalidades) que serão desenvolvidos nesta iteração, iremos realizar a divisão deles em componentes visuais. É nesta hora que iremos estabelecer componentes como:
Procuraremos estabelecer um nível de granularidade que evite completamente repetições (DRY) mas ao mesmo tempo tenha foco em praticidade. Vamos assumir que, nos wireframes recebidos da equipe de Design, temos o seguinte componente
Conseguimos extrair desta imagem os seguintes sub-componentes:
Cada um destes (1, 2, 3, 4), apesar de bem granulares, merecem ser um componente. Com isto, conseguiremos reutilizar estes mesmos elementos em outros contextos, caso seja preciso. Mas não deixaremos de criar o componente SearchInput, que é um componente que desenha e organiza os subcomponente 1, 2, 3, 4, nos proporcionando praticidade quando formos de fato construir nosso sistema de busca.
Todos os componentes nesta fase deverão ser stateless. Para garantir isto, iremos utilizar a sintaxe para declarar componentes com funções (React Functional Stateless Component):
const SearchInput = () => (
...
)
Isto irá nos proporcionar algumas vantagens:
É neste momento também que estabeleceremos, olhando para nossos wireframes/mockups, quais Props cada stateless component irá receber.
Neste momento, não economize em props: Absolutamente todos os parâmetros que descrevem o estado do componente e os eventos que ele pode emitir devem ser levados em consideração.
Até mesmo estados "superficiais" como por exemplo hover (Veremos nos mais adiante como otimizá-los)
const SearchInput = ({
// estado
text,
resultsCount,
hovered,
resultsCount,
// eventos
onChangeText,
onClickCancel,
onHover,
}) => (
...
)
É de extrema importância que sejam escritos os PropTypes. Eles nos ajudaram em diversos pontos:
import React, { PropTypes } from 'react'
const SearchInput = ({
// estado
text,
resultsCount,
hovered,
// callbacks
onChangeText,
onClickCancel,
onHover,
}) => (
...
)
const { string, number, bool, func } = PropTypes
SearchInput.propTypes = {
text: string.isRequired,
resultsCount: number,
hovered: bool.isRequired,
onChangeText: func.isRequired,
onClickCancel: func.isRequired,
onHover: func.isRequired
}
OBS: Sempre iremos seguir esta ordem na desestruturação do objeto e declaração das propTypes: Primeiro propriedades de estado do componente e depois callbacks (funções)
Todas as props que não forem required devem especificar uma defaultProp Utilize este recurso para tornar mais prático o uso de seus componentes
SearchInput.defaultProps = {
resultsCount: 0
}
Até o momento, não escrevemos o código dos componentes propriamente ditos, apenas estabelecemos a divisão lógica entre os diversos componentes e quais parâmetros cada um pode receber.
Contudo, já temos em mãos como esperamos que o componente se comporte quando submetido a diferentes parâmetros
(pois temos em mãos os wireframes/mockups), então é uma ótima oportunidade para aplicar o TDD
Começaremos escrevendo todas as estórias do nosso componente:
const actionHandlers = {
onChangeText: action('onChangeText'),
onClickCancel: action('onClickCancel'),
onHover: action('onHover')
}
storiesOf('SearchInput', module)
.add('With some text, no results', () => (
<SearchInput
text="Como os dinoss"
resultsCount={0}
hovered={false}
{...actionHandlers}
/>
))
.add('No text with some results', () => (
<SearchInput
text=""
resultsCount={10}
hovered={false}
{...actionHandlers}
/>
))
.add('With some text and hovered', () => (
<SearchInput
text="Como os dinoss"
resultsCount={0}
hovered
{...actionHandlers}
/>
))
.add('With some text and results', () => (
<SearchInput
text="Como os dinoss"
resultsCount={10}
hovered={false}
{...actionHandlers}
/>
))
Repare que, como no momento não estamos interessados em alterar o estado devido à emissão de eventos, estamos passando a função retornada pela chamada do action(String)
,
que apenas logará no console do Storybook que a função foi chamada e quais parâmetros e ela foram passados.
É claro que, devido às diferentes combinações de props, o número de estórias irá crescer muito. Se preocupe em cobrir os casos mais comuns e que, pelo menos visualmente, representem todos os estados que o componente pode assumir. Lembre-se também de alguns casos que podem gerar problemas (Por exemplo a estória #3, onde não há nada nada busca mas existem resultados)
As vantagens de se usar o storybook são inúmeras:
Neste momento, iremos de fato escrever nosso componente, mas antes, vamos falar sobre estilização.
Há diversas formas de fazer estilização com React, mas para esta metodologia iremos utilizar a biblioteca styled-components
Primeiro, escreva a estrutura do seu componente, sem se preocupar com css:
import Input from '@components/InputWrapper'
import SearchIcon from '@components/SearchIcon'
import ResultsCountChip from '@components/ResultsCountChip'
import styled from 'styled-components'
const Container = styled.div`
/*styles aqui */
`
const InputWrapper = styled.div`
/*styles aqui */
`
const CancelButton = styled.button`
/*styles aqui */
`
const SearchInput = ({
// estado
text,
resultsCount,
hovered,
resultsCount,
// eventos
onChangeText,
onClickCancel,
onHover,
onMouseLeave,
}) => (
<Container onMouseEnter={onHover} onMouseLeave={onMouseLeave}>
<SearchIcon active={hovered} />
<InputWrapper>
<Input value={text} onChange={e => onChangeText(e.target.value)} />
</InputWrapper>
<ResultsCountChip count={resultsCount}/>
<CancelButton onClick={onClickCancel} resultsCount={resultsCount} />
</Container>
)
Repare que Container
e InputWrapper
são componentes locais, criados utilizado o hoc Styled
(styled.div é apenas um alias para Styled('div'))
A partir de agora é só estilizar. Propriedades de css que são calculadas a partir de props são facilmente implementadas utilizando interpolação de strings:
const CancelButton = styled.button`
height: 50px;
width: 200px;
background-color: ${props => props.resultsCount > 0 ? '#F00' : '#0F0'};
`
O único caso em que iremos declarar classes diretamente no css é caso precisemos utilizar o ReactCSSTransitionGroup, que depende de classes globais. Fora isso, todo o estilo estará escopado por componente, fazendo com que ele se torne verdadeiramente um componente autosuficiente, que irá se comportar da mesma maneira independente do contexto em que for renderizado.
Algumas vantagens em utilizar o styled-components
E como reaproveitaremos estilos?
Diferentemente do CSS convencional, não criaremos estilos globais para reaproveitá-los. O que faremos é o reaproveitamnete de Components Vamos supor que todos nossos componentes de texto tenham estas propriedades
color: red;
font-size: 16px;
font-family: Nunito Sans;
Ao invez de criarmos uma classe global ou mesmo aplicar estes estilos em um escopo ainda mais genérico (body, por exemplo), iremos criar um componente Text
const Text = styled.span`
color: #FFF;
font-size: 16px;
font-family: Nunito Sans;
`
E sempre que precisarmos destas características, importaremos o componente Text
e o utilizaremos.
E caso precisemos criar componentes que tem como base as características de Text
,
porém com customizações? É simples
const Title = styled(Text)`
font-weight: 900;
font-size: 26px;
`
Neste momento teremos todos os componentes visuais criados, testados e documentados, apenas esperando para receber os dados e funções vindas dos containers.
Porém há alguns passos antes de chegarmos no que propriamente vai ditar a lógica da nossa aplicação
Nesta fase, criaremos as rotas envolvidas nesta iteração utilizando o react-router v4
.
Criaremos também uma outra categoria de componentes: as Pages
Nossas routes irão sempre desenhar Pages, e por mais que a Page tenha apenas um componente (um Container, por exemplo), é importante criar esta camada.
A vantagem é que, criando esta interface entre o container e a rota, não estaremos acoplando o nosso container com como a rota trata os params da rota, por exemplo
const SearchPage = ({
match: {
params: {
categoryId
}
}
}) => {
return (
<SearchContainer
categoryIdFilter={categoryId}
/>
)
}
Neste exemplo nosso SearchContainer
recebe o categoryId, e no nosso caso de uso este categoryId está na rota (por exemplo /search/:categoryId
)
O papel do SearchPage
, neste caso, é ler do match.params (específico do nosso Router) o categoryId e repassar para o componente.
Sendo assim, nosso 'SearchContainer' não está atrelado a nosso Router e pode ser utilizado em outros contextos, por exemplo com o categoryId vindo de outra fonte de dados
Neste passo, iremos analisar as funcionalidades e casos de uso para determinar o mínimo que devemos guardar na nossa store para representar o estado da aplicação.
Por que mínimo ? Não devemos guardar valores computados, pois estes devem ser calculados e repassados para os componentes nos Containers. Veremos mais sobre este assunto no tópico sobre Containers.
Mas, para exemplificar, vamos imaginar que estamos fazendo uma calculadora que mostra o resultado da multiplicação de dois números, o número a e o número b O resultado da multiplicação, que chamaremos de c, não precisa e não deve ser guardado na nossa store, pois ele é um valor computado a partir de outros valores, no caso, c = a * b.
Nesta fase, não devemos nos preocupar com o estado referente à dados buscados do backend (por exemplo os posts do usuário) pois, como veremos a seguir, este tipo de estado será gerenciado pelo nosso client de GraphQL. Pense apenas em estados locais, como por exemplo qual filtro está selecionado, itens em um carrinho de compras, formulários*, etc
(*) Caso você opte por utilizar o redux-form
(recomendado), você não precisará se preocupar com estado referente à forms.
Lembra-se quando conversamos sobre estados efêmeros, que não importam para o resto da aplicação? Neste momento, tendo uma visão mais geral do nosso State shape, é a hora de decidir quais partes do estado da nossa aplicação podem ser gerenciados localmente no componente e não na store do redux.
Por exemplo, se um componente muda de cor ao sofrer um hover e nenhum outro componente do nosso app precisa ficar sabendo disto, encontramos um bom candidato a ser transferido para um estado local. Porém não queremos modificar nossos componentes stateless criados na primeira fase (transformando-os em classes, capazes de ter estado).
Para resolver este problema, utilizaremos a biblioteca recompose
, criando enhancers
que darão capacidade a nossos componentes de terem um estado
import { withState, withHandlers, compose } from 'recompose'
const withHoverState = withState('hovered', 'setHovered', false)
const withHoverHandlers = ({
onMouseOver: ({ setHover }) => () => setHover(true)
onMouseOut: ({ setHover }) => () => setHover(false)
})
const enhanceWithHover = compose(withHoverState, withHoverHandlers)
const SearchInputWithHover = enhanceWithHover(SearchInput)
Pronto, agora nosso componente SearchInputWithHover
é um SearchInput
mas com capacidades de gerenciar e modificar seu estado em relação ao hover (:
Esta é a parte mais fascinante e satisfatória da metodologia, onde veremos nossos componentes recebendo dados reais vindos por exemplo do nosso backend
Utilizaremos o Apollo
como client de GraphQL.
Vamos assumir que recebemos da equipe de design o seguinte componente:
Provavelmente, os componentes desta lista (TodoList) que desenhamos na primeira fase estão esperando um array de objetos Todo. É bem fácil popular a nossa lista com um client de GraphQL
const query = gql`
query todos {
todos {
id
name
done
tag {
color
}
}
}
`
const ListWithData = graphql(query)(TodoList) //*
OBS: *O componente ListWithData é considerado um Container
, mas não é um Container
completo para nossa aplicação pois ele não interage com o estado local.
No tópico sobre containers, iremos completar a metade que falta deste componente para que ele possa, através do connect do redux, receber props que estão guardadas no estado local (mapStateToProps) e poder dispachar ações que modifiquem o estado local (mapDispatchToProps)*
O ideal é que o componente esteja esperando exatamente o mesmo formato de dados que está descrito na nossa schema de GraphQL.
Caso precise tratar os dados antes de passá-los para o componente, utilize as queryOptions
do Apollo.
Por exemplo, supondo que nosso componente TechnologiesList e TechnologiesListItem esperem que a cor esteja na raiz do objeto de tecnologia
(eles não sabem da existência da key tag
), podemos tratar os dados da seguinte maneira:
const queryOptions = {
props: ({ ownProps, data: { loading, todos, refetch } }) => ({
loading,
refetch,
todos: todos && todos.map(todo => ({
...todo,
color: todo.tag.color,
}))
}),
}
É recomendado também o uso de fragments
para evitar repetição. (Mais sobre este tópico no futuro)
O Apollo
será responsável por trazer para o client todos os dados que necessários e que estejam necessariamente no backend (não são dados de estado local,
como por exemplo qual filtro está selecionado).
Todas as operações (create, remove, update, etc) que precisem ser feitas no servidor serão disparadas pelo client através de mutations
Mais sobre este assunto em breve...
Algumas vantagens de usar GraphQL + Apollo ao invés de modelos mais convenciais como por exemplo REST:
O Redux
, apesar suas inúmeras vantagens, é muito verboso.
O que podemos fazer para melhorá-lo neste aspecto?
Iremos utilizar a biblioteca redux-actions
para nos ajudar com os actionCreators
e reducers
Para entender melhor sua utilização, basta ler a Documentação
Em construção...
Para escrever nossos testes, recomendo a utilização do Jest.
Mas antes, qual a importância de escrever bons testes para o desenvolvimento do projeto? O motivo mais óbvio é a garantia de que nosso software funciona, mas existem outras vantagens um pouco menos perceptíveis
Por estes e outros motivos, é extremamente importante que seja investido o tempo necessário para implementação de testes em todas as fases do projeto.
Os arquivos de testes devem ser colocados próximos aos arquivos sendo testados, por exemplo:
src/modules/todos/reducer.js
-> src/modules/todos/__tests__/reducer.spec.js
Em construção...
Para testar nossos reducers
, selectors
e actionCreators
, iremos utilizars o redux-testkit para nos ajudar. Ele irá reduzir consideravelmente o boilerplate necessário para testar essa camada da aplicação.
Em construção...
Iremos utilizar o enzyme e o enzyme-to-json Em construção...
Os selectors são as funções responsáveis por extrair os dados da nossa store e transformá-los de alguma maneira que é mais interessante para a nossa View. Vamos imaginar que temos a seguinte estrutura de dados na nossa store:
{
todos: {
todo1: {
text: 'This is the first todo',
done: false
},
todo2: {
text: 'This is the second todo',
done: true
}
}
}
Porém, nosso dumb component TodosList
precisa receber um Array
de ToDos para serem renderizados, e não um mapa.
Vamos criar um selector que fará a transformação dos dados de maneira apropriada:
const getTodosMap = state => state.todos
const getTodosArrayFromMap = todosMap => Object.keys(todosMap).map(todoKey => ({
id: todoKey,
...todosMap[todoKey]
}))
Agora, podemos obter o Array
de ToDos a partir do state
:
const todosArray = getTodosArrayFromMap(getTodosMap(state))
E esta função será utilizada em nossos containers, que serão explicados com mais detalhe no próximo capítulo.
Mas antes, vamos pensar um pouco sobre um assunto importante: Performance.
Toda vez que alguma mudança de estado causar a rerenderização deste componente (o Container, que usa o selector), a função getTodosArray será chamada novamente e irá consumir os recursos computacionais necessários para sua execução, mesmo que o subset do state utilizado por esta função não tenha mudado e que o resultado da computação seja o mesmo.
Muitas vezes, não temos como evitar a rerenderização do componente, porém, podemos otimizar a computação da função através da memoization.
A idéia básica é, dada uma função pura (para o mesmo input X temos o mesmo output Y, sem side-effects), podemos fazer um cache do resultado Y, evitando a recomputação da função para um mesmo input X.
O caso descrito acima é um exemplo de onde podemos aplicar este conceito e temos uma biblioteca pronta para implementar memoization em JS: reselect
Com ela, nosso selector ficaria assim:
const getTodosArray = createSelector(
getTodosMap,
todosMap => getTodosArrayFromMap(todosMap)
)
const todosArray = getTodosArray(state)
Agora, caso a função getTodosMap
retorne um mesmo valor para dois valores de state
, a função getTodosArrayFromMap
será chamada apenas uma vez (para a primeira computação, após isto o valor será cacheado e retornado diretamente, em uma espécie de short-circuit)
Em construção...