OBS.: A proposta considera uma aplicação React utilizando redux e redux-thunk. Porém os conceitos propostos podem ser adaptados e utilizados com outros controladores de estado
Como você já deve saber, o React não é um framework e sim, uma biblioteca para criar interfaces de usuário. Isso quer dizer que, o React em si não vai e nem tem a pretensão (até então) de definir como sua aplicação deve ser estruturada/organizada e quer dizer também que, o React vai te dar maior liberdade para organizar sua aplicação da forma mais adequada às necessidades. Porém, parafraseando uncle Ben: "com grandes poderes, vem grandes responsabilidades" Dito isso, este artigo não tem como propósito entregar uma receita de bolo definitiva de como organizar sua aplicação React, mas sim, propor algumas ideias.
Bom, já que falamos de reponsabilidade, acredito que esse seja o cerne dos problemas de organização de uma aplicação React, não separamos bem as reponsabilidades. E isso é o que os frameworks como Angular, ao meu ver, fazem bem. O React é uma lib de UI com escopo de lidar com a renderização através de Componentes. Porém, quando essa ideia não fica clara, acabamos trazendo para os componentes várias outras responsabilidades
- componentes lidando diretamente com chamadas de APIs
- componentes tratando exceções
- componentes tendo que "entender" regras de negócio
- componentes fazendo muita manipulação de dados
- componentes lidando diretamente com recursos do browser
O React é uma lib declarativa, isso significa que quando construímos um componente, nós devemos preocupar mais com O QUE e deixar para o React lidar com O COMO renderizar. Seria bom se usássemos essa mesma abordagem - O QUE/COMO - para separarmos o que é responsabilidade do componente e o que não é e assim mantermos nossos componentes menos imperativos e mais declarativos possível, não só na parte da renderização (que já é abstraída pelo React). Para isso precisamos separar responsabilidades, começando pela estrutura de pastas.
Se você procurar na documentação oficial do React encontrará duas abordagens para estruturação de pastas: agrupamento por arquivos de mesmo tipo e agrupamento por features
- Isso quer dizer que existe pastas como: containers, components, actions, reducers
- Essas pastas contém arquivos de todos os contextos/funcionalidades do sistema
-
o número de arquivos por pasta cresce consideravelmente a medida que a aplicação evolui
-
arquivos correlatos de uma mesma funcionalidade, que geralemente precisam ser modificados em conjunto, ficam muito distantes uns dos outros
-
vários níveis de aninhamento de subpasta
- dificulta encontrar o que precisamos
- dificulta transição entre arquivos e imports relativos
- quando for realmente necessário aninhamento, não ultrapasse 3 níveis: recomendação
- Agrupamento de arquivos por features
- Cada feature deve conter todos os arquivos correlatos
- Dentro da pasta da feature havera algumas subpastas e arquivos para separar resposabilidades. Porém não deve haver mais de 3 níveis:
-
FeatureStore
- Path:
src/Features/pastaFeature/store
- O nome Store é genérico, mas você pode chamar essa subpasta pelo nome da lib que estiver usando para controle de estado global. Ex.: redux
- Aqui vai todos os arquivos relacionados a controle de estado global da Feature: actions, operations, reducer.
store/nomeFeatureActions
: para actions síncronas que disparam um único dispatchstore/nomeFeatureOperations
: É possível que uma action dispare multiplos dispatches e/ou faça algo assíncrono. Por serem "actions epeciais", vamos chama-las de Operationsstore/nomeFeatureReducer
: É onde será feito a atualização de fato do estado Feature dado a ocorrência de alguma action Nos próximos pontos, quando mencionar Store, estarei me referindo a esses três arquivos.
- Path:
-
FeatureHooks
- Path:
src/Features/pastaFeature/hooks
- Pode ser que uma mesma lógica que envolva uso de hooks do React ou de terceiros seja útil em mais de um componente da Feature. Nesse caso extraia a lógica para hooks customizados que serão usados somente no contexto da Feature
- Path:
-
FeatureViews
- Path:
src/Features/pastaFeature/views
- Aqui vai todos os arquivos referentes a renderização, ou seja, componentes da Feature.
- A reponsabilidade do componente deve se conter na renderização. Deixe os demais arquivos da Feature lidarem com as complexidades de chamadas de APIs, regras de negócio, manipulação de dados, validações, etc.
- Tente manter seu componente menos imperativo e mais declarativo
- Use extensão
.JSX
para componentes
- Path:
-
FeatureService
- Path:
src/Features/pastaFeature/nomeFeatureService
- Responsável pela comunicação da Feature com mundo externo: chamadas à APIs, banco de dados local (no caso de aplicações PWA).
- Abstrai dos Componentes e da Store a complexidade do COMO buscar/enviar dados
- Path:
-
FeatureManager
- Path: src/Features/pastaFeature/nomeFeatureManager
- Responsável por intermediar a comunicação de Componentes e Store com a Service
- Abstrai dos Componentes e Store a complexidade de regras de negócio aplicadas sobre input/output de dados da Feature. Os Componentes e a Store não precisam saber COMO input/output são transformados
-
FeatureUtils
- Path:
src/Features/pastaFeature/nomeFeatureUtils
- Funções úteis que só fazem sentido no contexto da Feature.
- Abstrai dos demais arquivos da Feature (Componentes, Store, Manager) a complexidade de funções de granularidade fina. As conhecidas "funções de escovar bit".
- Path:
-
index.js
- Path: src/Features/pastaFeature/index.js
- Responsável somente por exportar para aplicação somente o que é necessário da Feature
- Não deve conter implementação.
-
Tests
-
Path: uma pasta tests para cada nível da Feature
-
src/Features/pastaFeature/tests
: para testes unitários de FeatureManager, FeatureUtils, FeatureService -
src/Features/pastaFeature/store/tests
: para testes unitários de FeatureActions, FeatureOperations, FeatureReducer -
src/Features/pastaFeature/views/tests
: para testes unitários de Componentes
É claro que numa aplicação real teremos coisas que serão genéricas o suficiente que possam ser usadas em várias Features (componentes, hooks, funções úteis, constantes).
- Path:
src/Shared
-
Store com responsabilidades que não implicam controle de estado
-
Controle de estado, regras de negócio e comunicação com serviço mesclados numa coisa só
-
Actions chamando serviços
-
Actions implementando regras de negócio
-
Actions tratantando e manipulando dados de saida para enviar para serviços
-
Actions tratantando e manipulando dados de entrada para alterar estado global
-
Cada feature terá seu "pedaço" do estado global sendo
-
pastaFeature/store/nomeFeatureActions
-
pastaFeature/store/nomeFeatureOperations
-
pastaFeature/store/nomeFeatureReducer
-
featureActions
-
featureOperations
-
featureReducers
-
Componentes
- Consomem props de componentes "pais" ou do estado global
- Usam
nomeFeatureManager
caso precise comunicar com API/repositório - Usam
nomeFeatureUtils
eshared/utils
para funções de granularidade fina - Controlam seus estados locais
- Controlam seus effects locais
- Usam hooks da feature, da
shared/hooks
ou de terceiros para preparar o componente para renderização - Renderizam
- Mantem-se o mais declarativo possível
- Preocupam mais com O QUE
-
Store
- Reage às actions alterando os estados de acordo
- Arquivos actions ou operations não devem fazer chamadas diretas à serviços ou repositórios locais
- Arquivos actions ou operations não devem implementar regras de negócio sobre comunicação/respostas com serviços
- operations devem usar
nomeFeatureManager
para input/output de dados no estado global - Preocupam mais com O QUE
-
Manager, Service, Utils
- Auxiliam Componentes e Store abstraindo complexidades controle de estado, regras de negocio, input/output de dados, etc.
- Preocupam mais com O COMO
-
Alteração de estado com Actions - Regras
- ações simples
- atômicas
- sincronas
-
Alteração de estado com Actions - Fluxo
- featureContainers ou featureComponents chama action
const meuComponente = (props) => { //..em algum lugar no component/container props.minhaAction(); };
- action retorna: type/payload
const minhaAction = (parametroOpcional) => { return { type: 'TYPE_MINHA_ACTION', payload: true }; };
- reducer altera estado
export default (state = INITIAL_STATE, action) => { if (action.type === 'TYPE_MINHA_ACTION') { return { ...state, value: action.payload }; } return state; };
- o estado é alterado e os container/components que "escutam" essa alteração recebem o estado alterado via props
- featureContainers ou featureComponents chama action
-
Alteração de estado com Operations - Regras
- operações que precisam executar multiplas ações
- operações assíncronas
-
Alteração de estado com Operations - Fluxo chamada de serviço
-
featureContainers ou featureComponents chamam a operação
const meuComponente = (props) => { //..em algum lugar no component/container props.meuOperation(); };
-
featureOperations
- comunica com featureManager
- realiza dispatches
- trata exceção
const meuOperation = (parametrosOpcionais) => async (dispatch) => { try { dispatch(minhaAction()); const dadosQuePreciso = await featureManager.obtemDados(parametrosOpcionais); dispatch(actionIncluiDadoNoRedux(dadosQuePreciso)); } catch (error) { dispatch(actionNotificaErro(error.message)); } };
-
featureManager
- prepara parametros para requisição
- chama featureService
- trata resposta ou exceção
class FeatureManager { async obtemDados(parametrosOpcionais) { try { const filtro = featureUtils.montaFiltro(parametrosOpcionais); const respostaServico = await featureService.obtemDados(filtro); let meuDadoTratado = featureUtils.trataMeuDado(resposta.data); return meuDadoTratado; } catch (error) { throw mensagensErro.ERRO_AO_OBTER_MEU_DADO; } } }
Exemplo
-
featureService
- somente faz a requisição
- não trata exceção de requisição
class FeatureService { async obtemDados(filtro) { return axios.get(`${URL_SERVICO}/${filtro}`); } }
Exemplo
-
- Existem cenários em que já existe uma service de outra featureA que retorna o dado que a featureB corrente precisa
- Nesses cenários
- crie uma função no featureAManager que comunique com featureBManager
- featureBManager comunica com featureBService para retornar o dado para featureManager
- assim se o dado precisar ser manipulado para featureA fica a cargo de featureAManager tratar de forma que featureBManager e featureBService não precise mudar suas implementações
- Bundle da aplicação muito grandes e sendo carregado em primeiro instante
- Components
- má componentização
- class components que podem ser function components
- components com renderizações desnecessárias
- duplicação de código em components
- Roteamento de components - code splitting - react-router
- geralmente features tem um componente raíz, um componente macro, como um container ou uma page
- rotear não melhora performance, mas facilita implementar code splitting na aplicação
- assim o bundle da aplicação pode fragmentado em partes menores e essas podem ser carregadas de forma assíncrona e por demanda
- Usar técnicas recomendadas pelo React para optimizar components e renderizações desnecessárias
- transformar class components em function components
- components que não usam state e/ou lifecycle NÃO precisam ser criados como class
- function components são mais performáticos em detrimento a class components
- IMPORTANTE caso a versão do react for >= 16.8 use hooks, assim todos os componentes poderão ser function components
- optimizar renderizações
- existe cenários que components são re-renderizados mesmo que os mesmos não sofreram alterações em seu state e/ou props
- em class components isso pode ser evitado implementando o lifecycle shouldComponentUpdate()
- em function components isso pode ser evitado usando o React.memo
- utilize hooks como useMemo e useCallback para memoizar resultados e criação funções
- use Profiler API para identicar renderizações desnecessárias e gargalos em renderizações dos componentes
- transformar class components em function components
- Criar ou usar biblioteca de components
- Material UI, React Bootstrap, Design system próprio
- reduz duplicação de código
- reduz quantidade e aumenta qualidade de testes unitários de components
- reduz duplicação de estilização
- reduz tempo de implementação e facilita manutenção
- aumenta consistência da aplicação
- components reusáveis com renderização optimizada reduz o tempo de renderização de parent components
- reduz o bundle da aplicação
- Melhoria na performance da aplicação
- Redução no tamanho do bundle da aplicação
- Potencialização da escalabilidade da aplicação
- Melhoria na organização do código
- Facilidade no entendimento da aplicação
- Redução na curva de onboarding de novos membros na equipe
- Aumento do reuso de código
- Redução da quantidade de testes unitários
- Redução no tempo para manutenibilidade da aplicação
Em alguns dos projetos que essa proposta foi aplicada, foram feitas/sugeridas algumas adaptações que talvez faça sentido para o seu projeto
- FeatureManager e FeatureService: serem colocadas numa subpasta da Feature, em vez de ficar na pasta raiz.
/pastaFeature/api/FeatureService
/pastaFeature/api/FeatureManager
- FeatureService: mudar nome para FeatureRepository
- FeatureService: não ficar dentro da pasta da Feature. Em vez disso, ser um recurso compartilhado numa subpasta na Shared.
src/Shared/api/FeatureService
- Componentes: Para cada componente, seja da Feature ou compartilhado, criar uma pasta e não ser só o
Arquivo.jsx
em si
-
Components muito grandes
- sugestão: estabelecer um máximo de linhas por componente
-
Separar container components de presentational components. Ver
-
Extraia de componentes mais complexos, partes que podem ser componentizadas em componentes menores, mais simples e possivelmente reusáveis
-
Estilizar componentes usando css module.
-
Estilizar componentes usando classes dinâmicas.
-
Em containers, components, manager, service
- Não fazer tratamento/validações de dados (map, filter, transformar objetos, etc.)
- crie funções no arquivo featureUtils quando fizer sentido só no contexto da feature
- crie funções no arquivo shared/utils geral quando for uma função que pode ser usada em outros contextos
- Não fazer tratamento/validações de dados (map, filter, transformar objetos, etc.)
-
Veja mais dicas aqui
-
blocos if/else
// evite condições muito grandes ou muitas condições nos if's if (variavelUm === 'bla' && variavelDois[0].valor === 2 && variavelTres.length > 0) { // faz alguma coisa } // Sugestão: associar condições a variáveis com nomes descritivos const condicaoUm = variavelUm === 'bla'; const condicaoDois = variavelDois[0].valor === 2; const condicaoTres = variavelTres.length > 0; const deveFazerAlgumaCoisa = condicaoUm && condicaoDois && condicaoTres; if (deveFazerAlgumaCoisa) { // faz alguma coisa }
-
Nomes descritivos
// Nomes não intuitivos/descritivos const idades = [1, 2, 3]; const filtradas = pessoas.filter((pessoa) => idades.includes(pessoa.idade)); // Sugestão const idadesFiltrar = [1, 2, 3]; const pessoasFitlradasPorIdade = pessoas.filter((pessoa) => idadesFiltrar.includes(pessoa.idade));
-
Funções com responsabilidade única
// Note que a função não está só filtrando as pessoas por idade const idadesFiltrar = [1, 2, 3]; const filtraPessoasPorIdade = (pessoas, idadesFiltrar) => { return pessoas .filter((pessoa) => idadesFiltrar.includes(pessoa.idade)) .sort((a, b) => a.idade - b.idade); }; // Sugestão const filtraPessoasPorIdade = (pessoas, idadesFiltrar) => { return pessoas.filter((pessoa) => idadesFiltrar.includes(pessoa.idade)); }; const ordenaPessoasPorIdade = (pessoas) => { return pessoas.sort((a, b) => a.idade - b.idade); };
-
Uma função é dita pura se ela atende minimamente dois requisitos
- Ela não causa efeitos colaterais como alterar algo fora do seu escopo
// Função impura - Causa efeito colateral altereando uma variável fora de seu escopo let contador = 0; const incrementaContador = (val) => { return (contador += val); // note que depois dessa linha o contador não será mais 0 }; // Sugestão let contador = 0; const incrementaContador = (val) => { const contadorIncrementado = contador + val; return contadorIncrementado; };
- Ela sempre retorna o mesma coisa dado um mesmo parâmentro
// Função impura - Não retorna o mesmo valor dado o mesmo parâmetro let valor = 0; // ..codigos // não podemos garantir que essa função sempre retorna o mesmo resultado // porque ela usa "coisas" de fora que podem ser alterados const valorMaiorQueZero = () => { return valor > 0; }; // Sugestão - Passar parâmetro const valorMaiorQueZero = (valorAvaliar) => { return valorAvaliar > 0; };
-
O conceito aberto/fechado nos príncipios do SOLID em POO é uma classe aberta para extensão, mas fechada para alterações
-
Em JS podemos criar funções mais genéricas e extender seu uso/comportamento criando funções especializadas que usam as genéricas em diferentes contextos
- assim não precisamos alterar as funções mais genéricas para uso em diferentes contextos
// As vezes repetimos uma lógica que pode ser generalizada aplicando aberto/fechado const pessoas = [ { nome: 'a', idade: 1 }, { nome: 'b', idade: 2 }, ]; const trataArrayPessoas = (pessoas) => { const pessoasComDezoitoAnos = pessoas.filter((pessoa) => pessoa.idade === 18); const pessoasFiltradasPorNome = pessoasComDezoitoAnos.filter((pessoa) => pessoa.nome === 'a'); }; // Sugestão const trataArrayPessoas = (pessoas) => { const pessoasComDezoitoAnos = filtraObjetosPorPropriedade(pessoas, 'idade', 18); const pessoasFiltradasPorNome = filtraObjetosPorPropriedade(pessoas, 'nome', 'a'); }; // note que o comportamento da função pode ser extendido // sem a necessidade de alterar a função para cada contexto const filtraObjetosPorPropriedade = (objetos, propriedade, valor) => { const objetosFiltrados = objetos.filter((objeto) => objeto[propriedade] === valor); };
-
Encapsular uso de packages de terceiros
// Isolar uso de bibliotecas te terceiros // usando lodash const pessoasComDezoitoAnos = _.filter(pessoas, { idade: 18 }); // Sugestão const filtrapessoasComDezoitoAnos = (pessoas) => { return _.filter(pessoas, { idade: 18 }); };