React components composition: como acertar isso
doug-source
Posted on July 18, 2024
Nota: apenas traduzi o texto abaixo e postei aqui. Atualizei um pouco apenas os códigos. As referências estão no fim deste artigo.
Publicado originalmente em https://www.developerway.com. O site tem mais artigos como este. 😉
Uma das coisas mais interessantes e desafiadoras no React é não dominar algumas técnicas avançadas de gerenciamento de state ou como usar o Context de maneira adequada. Mais complicado de acertar é como e quando devemos separar nosso código em components independentes e como compô-los adequadamente. Muitas vezes vejo desenvolvedores caindo em duas armadilhas: ou eles não extraem eles rápido o suficiente e acabam com enormes "monólitos" de components que fazem muitas coisas ao mesmo tempo e que são um pesadelo para manter. Ou, especialmente depois de terem sido queimados algumas vezes pelo pattern anterior, eles extraem components muito cedo, o que resulta em uma combinação complicada de múltiplas abstrações, código com engenharia excessiva e, novamente, um pesadelo para manter.
O que quero fazer hoje é oferecer algumas técnicas e regras que possam ajudar a identificar quando e como extrair components no prazo e como não cair na armadilha do excesso de engenharia. Mas primeiro, vamos atualizar alguns princípios básicos: o que é composition (composição) e quais composition patterns estão disponíveis para nós?
React components composition patterns
Components simples
Components simples são um bloco de construção básico do React. Eles podem aceitar props, ter algum state e podem ser bastante complicados, apesar do nome. Um Button component que aceita properties title
e onClick
e renderiza uma button tag é um component simples.
import { ComponentPropsWithoutRef, ReactNode } from 'react';
type ButtonProps = Omit<ComponentPropsWithoutRef<'button'>, 'title'> & {
title: ReactNode
};
const Button = ({ title, onClick }: ButtonProps) => (
<button onClick={onClick}>{title}</button>
);
Qualquer component pode renderizar outros components – isso é composition. Um Navigation
component que renderiza esse Button
- também um component simples, que compõe outros components:
// onClickHandler declarado aqui
const Navigation = () => (
<>
{/** Renderizando o Button component no Navigation component. **/}
{/** Composition! **/}
<Button title="Create" onClick={onClickHandler} />
{/** ... algum outro código de navigation **/}
</>
);
Com esses components e sua composition, podemos implementar UI tão complicada quanto quisermos. Tecnicamente, nem precisamos de outros patterns e técnicas, todos eles são apenas úteis que apenas melhoram a reutilização de código ou resolvem apenas casos de uso específicos.
Container components
Container components é uma técnica de composition mais avançada. A única diferença dos components simples é que eles, entre outras props, permitem passar a prop especial children, para os quais o React possui sua própria sintaxe. Se nosso Button do exemplo anterior aceitasse não o title, mas children, seria escrito assim:
// o código é exatamente o mesmo! basta substituir "title" por "children"
import { ComponentPropsWithoutRef } from 'react';
type ButtonProps = ComponentPropsWithoutRef<'button'>;
const Button = ({ children, onClick }: ButtonProps) => (
<button onClick={onClick}>{children}</button>
);
O que não é diferente do title na perspectiva do Button. A diferença está no lado do "consumer", a sintaxe children é especial e se parece com suas tags HTML normais:
// onClickHandler declarado aqui
const Navigation = () => {
return (
<>
<Button onClick={onClickHandler}>Create</Button>
{/** ... algum outro código de navigation **/}
</>
);
};
Qualquer coisa pode entrar em children
. Podemos, por exemplo, adicionar um Icon
component além do texto, e então Navigation
terá uma composition de components Button
e Icon
:
// onClickHandler declarado aqui
const Navigation = () => (
<>
<Button onClick={onClickHandler}>
{/** Icon component é renderizado dentro de button, **/}
{/** mas button não sabe **/}
<Icon />
<span>Create</span>
</Button>
{/** ... algum outro código de navigation **/}
</>
);
Navigation controla o que acontece com children; da perspectiva de Button, ela apenas renderiza tudo o que o "consumer" deseja.
Veremos mais exemplos práticos dessa técnica mais adiante neste artigo.
Existem outros composition patterns, como higher-order components, passando components como props ou context, mas esses devem ser usados apenas para casos de uso muito específicos. Components simples e container components são os dois principais pilares do desenvolvimento do React, e é melhor aperfeiçoar o uso deles antes de tentar introduzir técnicas mais avançadas.
Agora que você os conhece, está pronto para implementar a UI mais complicada que precisar!
Ok, estou brincando, não vou fazer um artigo do tipo "como desenhar uma coruja" aqui. 😅
É hora de algumas regras e diretrizes para que possamos realmente desenhar aquela coruja e construir React apps complicados com facilidade.
Quando é um bom momento para extrair components?
As principais regras de desenvolvimento e decomposition do React que gosto de seguir, e quanto mais codifico, mais fortemente me sinto a respeito delas, são:
- sempre comece a implementação do topo
- extrair components somente quando houver uma necessidade real
- sempre comece com components "simples", introduza outras técnicas de composition somente quando houver real necessidade delas
Qualquer tentativa de pensar "com antecedência" ou começar "de baixo para cima" a partir de pequenos components reutilizáveis sempre termina em APIs de components excessivamente complicadas ou em components que carecem de metade da funcionalidade necessária.
E a primeira regra para quando um component precisa ser decomposto em components menores é quando um component é muito grande. Um bom tamanho para um component para mim é quando ele cabe inteiramente na tela do meu laptop. Se eu precisar realizar scroll para ler o código do component, é um sinal claro de que ele é muito grande.
Vamos começar a codificar agora, para ver como isso funciona na prática. Vamos implementar uma página Jira típica do zero hoje, nada menos (bem, mais ou menos, pelo menos vamos começar 😅).
Esta é a tela de uma página de edição do meu projeto pessoal onde guardo minhas receitas favoritas encontradas online 🍣. Lá precisamos implementar, como você pode ver:
- top bar (barra superior) com logo, alguns menus, button "create" e search bar (barra de pesquisa)
- sidebar (barra lateral) à esquerda, com o nome do projeto, seções collapsable de "planejamento" e "desenvolvimento" com itens dentro (também divididos em grupos), com uma seção sem nome com menu items abaixo
- uma grande seção de "conteúdo da página", onde são mostradas todas as informações sobre o issue atual
Então, vamos começar a codificar tudo isso em apenas um grande component. Provavelmente será algo assim:
export const JiraIssuePage = () => (
<div className="app">
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
{/** mais top bar items aqui, como **/}
{/** SearchBar e ProfileMenu **/}
</div>
<div className="main-content">
<div className="sidebar">
<div className="sidebar-header">ELS project</div>
<div className="sidebar-section">
<div
className="sidebar-section-title"
>Planning</div>
<button className="board-picker">ELS board</button>
<ul className="section-menu">
<li>
<a href="#">Roadmap</a>
</li>
<li>
<a href="#">Backlog</a>
</li>
<li>
<a href="#">Kanban board</a>
</li>
<li>
<a href="#">Reports</a>
</li>
<li>
<a href="#">Roadmap</a>
</li>
</ul>
<ul className="section-menu">
<li>
<a href="#">Issues</a>
</li>
<li>
<a href="#">Components</a>
</li>
</ul>
</div>
<div className="sidebar-section">
sidebar development section
</div>
{/** outras sections **/}
</div>
<div className="page-content">
{/** ... aqui haverá muito código **/
{/** para visualização da issue **/}
</div>
</div>
</div>
);
Agora, não implementei nem metade dos itens necessários lá, sem falar na lógica, e o component já é grande demais para ser lido de uma só vez. Veja em codesandbox. Isso é bom e esperado! Portanto, antes de prosseguir, é hora de dividi-lo em partes mais gerenciáveis.
A única coisa que preciso fazer é criar alguns novos components e copiar e colar o código neles. Não tenho nenhum caso de uso para nenhuma das técnicas avançadas (ainda), então tudo será um component simples.
Vou criar um Topbar
component, que terá tudo relacionado à top bar, um Sidebar
component, para tudo relacionado à sidebar, como você pode imaginar, e um Issue
component para a parte principal que não iremos abordar hoje. Dessa forma, nosso component principal JiraIssuePage
fica com este código:
export const JiraIssuePage = () => (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
Agora vamos dar uma olhada na implementação do novo Topbar component:
export const Topbar = () => (
<div className="top-bar">
<div className="logo">logo</div>
<ul className="main-menu">
<li>
<a href="#">Your work</a>
</li>
<li>
<a href="#">Projects</a>
</li>
<li>
<a href="#">Filters</a>
</li>
<li>
<a href="#">Dashboards</a>
</li>
<li>
<a href="#">People</a>
</li>
<li>
<a href="#">Apps</a>
</li>
</ul>
<button className="create-button">Create</button>
{/** mais top bar items aqui, como **/}
{/** SearchBar e ProfileMenu **/}
</div>
);
Se eu implementasse todos os itens lá (searchbar, todos os submenus, icons à direita), esse component também seria muito grande, então também precisa ser dividido. E este é sem dúvida um caso mais interessante que o anterior. Porque, tecnicamente, posso simplesmente extrair o MainMenu
component dele para torná-lo pequeno o suficiente.
export const Topbar = () => (
<div className="top-bar">
<div className="logo">logo</div>
<MainMenu />
<button className="create-button">Create</button>
{/** mais top bar items aqui, como **/}
{/** SearchBar e ProfileMenu **/}
</div>
);
Mas extrair apenas MainMenu
tornou o Topbar
component um pouco mais difícil de ler para mim. Antes, quando eu olhava para Topbar
, eu poderia descrevê-lo como "um component que implementa várias coisas no topbar", e focar nos detalhes apenas quando preciso. Agora a descrição seria "um componente que implementa várias coisas na top bar E compõe algum component aleatório do MainMenu
". O fluxo de leitura está arruinado.
Isso me leva à minha segunda regra de decomposition de components: ao extrair components menores, não pare no meio do caminho. Um component deve ser descrito como um "component que implementa várias coisas" ou como um "component que compõe vários components juntos”, e não ambos.
Portanto, uma implementação muito melhor do Topbar component seria assim:
export const Topbar = () => (
<div className="top-bar">
<Logo />
<MainMenu />
<Create />
{/** mais top bar items aqui, como **/}
{/** SearchBar e ProfileMenu **/}
</div>
);
Muito mais fácil de ler agora!
E exatamente a mesma história com o Sidebar component - muito grande se eu tivesse implementado todos os itens, então preciso dividi-lo:
export const Sidebar = () => (
<div className="sidebar">
<Header />
<PlanningSection />
<DevelopmentSection />
{/** outras sidebar sections **/}
</div>
);
Veja o exemplo completo na caixa de códigos.
E então basta repetir essas etapas sempre que um componente ficar muito grande. Em teoria, podemos implementar toda esta página do Jira usando nada mais do que componentes simples.
Quando é a hora de apresentar os Container components?
Agora a parte divertida: vamos ver quando devemos apresentar algumas técnicas avançadas e por quê. Começando com Container components.
Primeiro, vamos dar uma olhada no design novamente. Mais especificamente - nas seções Planning e Development no sidebar menu.
Eles não apenas compartilham o mesmo design do title, mas também o mesmo comportamento: clicar no title recolhe a seção e, no modo "collapsed", o ícone de minisseta aparece. E nós o implementamos como dois components diferentes - PlanningSection
e DevelopmentSection
. Eu poderia, é claro, apenas implementar a lógica de "collapse" em ambos, afinal é apenas uma questão de state simples:
import { useState } from 'react';
const PlanningSection = () => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div
onClick={() => setIsCollapsed(!isCollapsed)}
className="sidebar-section-title"
>
Planning
</div>
{!isCollapsed && <>...todo o resto do código</>}
</div>
);
};
Mas:
- há muita repetição mesmo entre esses dois componentes
- o conteúdo dessas seções é realmente diferente para cada tipo de projeto ou tipo de página, portanto, ainda mais repetição no futuro próximo
Idealmente, quero encapsular a lógica do comportamento collapsed/expanded e o design do title, deixando diferentes seções com controle total sobre os itens que estão dentro. Este é um caso de uso perfeito para os Container components. Posso simplesmente extrair tudo do exemplo de código acima em um component e passar menu items como children
. Teremos um CollapsableSection
component:
import { ReactNode, useState } from 'react';
type CollapsableSectionProps = {
children: ReactNode;
title: ReactNode;
};
const CollapsableSection = ({
children,
title
}: CollapsableSectionProps) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="sidebar-section">
<div
className="sidebar-section-title"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{title}
</div>
{!isCollapsed && <>{children}</>}
</div>
);
};
e PlanningSection
(e DevelopmentSection
e todas as outras seções futuras) se tornarão apenas isto:
// import de CollapsableSection
const PlanningSection = () => (
<CollapsableSection title="Planning">
<button className="board-picker">ELS board</button>
<ul className="section-menu">... todos os menu items aqui</ul>
</CollapsableSection>
);
Uma história muito semelhante acontecerá com nosso root component JiraIssuePage
. No momento está assim:
// todos os imports dos components aqui
export const JiraIssuePage = () => (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">
<Issue />
</div>
</div>
</div>
);
Mas assim que começarmos a implementar outras páginas acessíveis a partir da sidebar, veremos que todas sigam exatamente o mesmo pattern - sidebar e topbar permanecem as mesmas, e apenas a área "conteúdo da página" muda. Graças ao trabalho de decomposition que fizemos antes, podemos simplesmente copiar e colar esse layout em cada página - afinal, não é tanto código. Mas como todos são exatamente iguais, seria bom apenas extrair o código que implementa todas as partes comuns e deixar apenas os components que mudam para as páginas específicas. Mais uma vez, um caso perfeito para o "container" component:
import { ReactNode } from 'react';
type JiraPageLayoutProps = {
children: ReactNode;
};
const JiraPageLayout = ({ children }: JiraPageLayoutProps) => (
<div className="app">
<Topbar />
<div className="main-content">
<Sidebar />
<div className="page-content">{children}</div>
</div>
</div>
);
E nosso JiraIssuePage
(e futuros JiraProjectPage
, JiraComponentsPage
, etc, todas as futuras páginas acessíveis na sidebar) se torna apenas isto:
export const JiraIssuePage = () => (
<JiraPageLayout>
<Issue />
</JiraPageLayout>
);
Se eu quisesse resumir a regra em apenas uma frase, poderia ser esta: extrair Container components quando houver necessidade de compartilhar alguma lógica visual ou comportamental que encapsule elements que ainda precisam estar sob controle do "consumer".
Container Components – caso de uso de desempenho
Outro caso de uso muito importante para Container components é melhorar o desempenho dos components. Tecnicamente a performance foge um pouco do assunto para a conversa sobre composition, mas seria um crime não mencioná-la aqui.
No Jira real, o Sidebar
component pode ser draggable - você pode resize ele fazendo o dragging dele para a esquerda e para a direita pela borda. Como implementaríamos algo assim? Provavelmente introduziríamos um Handle
component, algum state para a width
da sidebar e então faríamos o listen do "mousemove" event. Uma implementação rudimentar seria mais ou menos assim:
// import do Handle component
import { useState, useRef, useEffect } from 'react';
export const Sidebar = () => {
const [width, setWidth] = useState(240);
const [startMoving, setStartMoving] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const changeWidth = (e: MouseEvent) => {
if (!startMoving) return;
if (!ref.current) return;
const left = ref.current.getBoundingClientRect().left;
const wi = e.clientX - left;
setWidth(wi);
};
ref.current.addEventListener('mousemove', changeWidth);
return () => ref.current?.removeEventListener('mousemove', changeWidth);
}, [startMoving, ref]);
const onStartMoving = () => {
setStartMoving(true);
};
const onEndMoving = () => {
setStartMoving(false);
};
return (
<div
className="sidebar"
ref={ref}
onMouseLeave={onEndMoving}
style={{ width: `${width}px` }}
>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
{/* ... o resto do código */}
</div>
);
};
Há, no entanto, um problema aqui: cada vez que movemos o mouse, iremos disparar uma atualização de state, que por sua vez acionará a nova renderização de todo o Sidebar
component. Embora em nossa sidebar rudimentar não seja perceptível, isso pode tornar o "dragging" visivelmente lento quando o component se torna mais complicado. Container components são uma solução perfeita para isso: tudo o que precisamos é extrair todas as operações pesadas de state em um Container component e passar todo o resto pelos children
.
import { ReactNode } from 'react';
type DraggableSidebarProps = { children: ReactNode };
const DraggableSidebar = ({ children }: DraggableSidebarProps) => {
// todo o código de gestão do estado como antes
return (
<div
className="sidebar"
ref={ref}
onMouseLeave={onEndMoving}
style={{ width: `${width}px` }}
>
<Handle onMouseDown={onStartMoving} onMouseUp={onEndMoving} />
{/** children não seré afetado pelos **/}
{/** re-renders deste component **/}
{children}
</div>
);
};
E nosso Sidebar
component se transformará nisso:
// todos os imports dos components
export const Sidebar = () => (
<DraggableSidebar>
<Header />
<PlanningSection />
<DevelopmentSection />
{/* outras seções */}
</DraggableSidebar>
);
Dessa forma, o DraggableSidebar
component ainda será renderizado novamente a cada mudança de state, mas será muito barato, pois é apenas uma div. E tudo o que chega em children não será afetado pelas atualizações de state deste component.
Veja todos os exemplos de container components neste codesandbox. E para comparar o caso de uso de re-renders ruims, consulte este codesandbox. Preste atenção ao console output enquanto realiza o dragging do sidebar nesses exemplos - o PlanningSection
component loga constantemente na implementação "ruim" e apenas uma vez na implementação "boa".
E se você quiser saber mais sobre vários patterns e como eles influenciam o desempenho do React, você pode achar esses artigos interessantes: Como escrever código React de alto desempenho: regras, patterns, o que fazer e o que não fazer, Por que custom react hooks podem destruir o desempenho do seu app, Como escrever React apps de alto desempenho com Context
Este state pertence a este component?
Outra coisa, além do tamanho, que pode sinalizar que um component deve ser extraído, é o gerenciamento de state. Ou, para ser mais preciso, gerenciamento de state que é irrelevante para a funcionalidade do component. Deixe-me mostrar o que quero dizer.
Um dos itens da sidebar no Jira real é o item "Add shortcut", que abre um modal dialog quando você clica nele. Como você implementaria isso em nosso app? O modal dialog em si obviamente será seu próprio component, mas onde você introduziria o state que abre ele? Algo assim?
// import do ModalDialog component
import { useState } from 'react';
const SomeSection = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<span onClick={() => setShowAddShortcuts(true)}>
Add shortcuts
</span>
</li>
</ul>
{showAddShortcuts && (
<ModalDialog onClose={() => setShowAddShortcuts(false)} />
)}
</div>
);
};
Você pode ver algo assim em todos os lugares e não há nada de criminoso nesta implementação. Mas se eu estivesse implementando e quisesse tornar esse component perfeito do ponto de vista da composition, extrairia esse state e os components relacionados a ele externamente. E a razão é simples: esse state não tem nada a ver com o SomeSection
component. Este state controla um modal dialog que aparece quando você clica no shortcuts item (de atalhos). Isso torna a leitura deste component um pouco mais difícil para mim - vejo um component que é "section" e a próxima linha é algum state aleatório que não tem nada a ver com "section". Então, em vez da implementação acima, eu extrairia o item e o state que realmente pertence a esse item em seu próprio component:
// import do ModalDialog component
import { useState } from 'react';
const AddShortcutItem = () => {
const [showAddShortcuts, setShowAddShortcuts] = useState(false);
return (
<>
<span onClick={() => setShowAddShortcuts(true)}>Add shortcuts</span>
{showAddShortcuts && (
<ModalDialog onClose={() => setShowAddShortcuts(false)} />
)}
</>
);
};
E o section component se torna muito mais simples como bônus:
// import do AddShortcutItem component
const OtherSection = () => {
return (
<div className="sidebar-section">
<ul className="section-menu">
<li>
<AddShortcutItem />
</li>
</ul>
</div>
);
};
Veja ele no codesandbox.
Pela mesma lógica, no Topbar
component eu moveria o state futuro que controla os menus para um SomeDropdownMenu
component, todos os states relacionados à pesquisa para o Search
component e tudo relacionado à abertura do dialog "create issue" para o CreateIssue
component.
O que torna um component um "bom component"?
Uma última coisa antes de encerrar por hoje. No resumo quero escrever "o segredo de escrever apps escaláveis em React é extrair bons components no momento certo". Já cobrimos o "momento certo", mas o que exatamente é um "bom component"? Depois de tudo o que abordamos sobre composition até agora, acho que estou pronto para escrever uma definição e algumas regras aqui.
Um "bom component" é aquele que posso ler facilmente e entender o que faz à primeira vista.
Um "bom component" deve ter um bom nome autodescritivo. Sidebar
para um component que renderiza sidebar é um bom nome. CreateIssue
para um component que lida com a criação de issues é um bom nome. SidebarController
para um component que renderiza sidebar items específicos para a página "Issues" não é um bom nome (o nome indica que o component tem algum propósito genérico, não específico para uma página específica).
Um "bom component" não faz coisas que sejam irrelevantes para o seu propósito declarado. O Topbar
component que renderiza apenas itens na top bar e controla apenas o comportamento da topbar é um bom component. O Sidebar
component, que controla o state de várias modal dialogs, não é o melhor component.
Fechando bullet points
Agora posso escrever 😄! O segredo de escrever apps escaláveis no React é extrair bons components no momento certo, nada mais.
O que constitui um bom component?
- tamanho, que permite lê-lo sem scrolling
- nome, que indica o que faz
- nenhuma gestão de state irrelevante
- implementação fácil de ler
Quando é hora de dividir um component em components menores?
- quando um component é muito grande
- quando um component executa operações pesadas de gerenciamento de state que podem afetar o desempenho
- quando um component gerencia um state irrelevante
Quais são as regras gerais de composition dos components?
- sempre comece a implementação do topo
- extraia components somente quando você tiver um caso de uso real para ele, não antecipadamente
- sempre comece com os components simples, introduza técnicas avançadas somente quando forem realmente necessárias, não com antecedência
Por hoje é isso, espero que tenham gostado da leitura e achado útil!
Até a próxima ✌🏼
...
Publicado originalmente em https://www.developerway.com. O site tem mais artigos como este. 😉
Assine a newsletter, conecte-se no LinkedIn ou siga no Twitter para ser notificado assim que o próximo artigo for publicado.
Fonte
Artigo escrito por Nadia Makarevich.
Posted on July 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.