Em desenvolvimento, existem diversas soluções de boas práticas para que seu código ou seu ambiente de produção não se transformem em um completo caos. São recomendações testadas e aprovadas por diversos profissionais que ajudam a organizar o seu trabalho, muitas vezes facilitando a integração com o trabalho de outros desenvolvedores ou a manutenção no futuro.
Entra em cena então o SOLID, um acrônimo que engloba cinco princípios de design focados em desenvolvimento de software com orientação a objetos. O SOLID nasceu em 2020, fruto do engenheiro de software Robert C. Martin e pode ser aplicado em diversos cenários.
Konstantin Lebedev é um desenvolvedor web, palestrante e educador on-line, que se define como “muito apaixonado por escrever código que seja sustentável e bem testado”. Em um artigo publicado na internet, ele compartilha como utiliza os princípios do SOLID no desenvolvimento com React.
Com sua autorização, traduzimos e reproduzimos o artigo na íntegra:
“À medida que a indústria de software cresce e comete erros, as melhores práticas e bons princípios de design de software surgem e são conceituados para evitar a repetição dos mesmos erros no futuro. O mundo da programação orientada a objetos (OOP) em particular é uma mina de ouro dessas melhores práticas, e o SOLID é inquestionavelmente um dos mais influentes.
SOLID é uma sigla, onde cada letra representa um dos cinco princípios de design que são:
- Single responsibility principle (SRP – ou ‘Princípio da Responsabilidade Única’, em Português)
- Open-closed principle (OCP – ‘Princípio Aberto-Fechado’)
- Liskov substitution principle (LSP – ‘Princípio de Substituição de Liskov’)
- Interface segregation principle (ISP – ‘Princípio de Segregação da Interface’)
- Dependency inversion principle (DIP – ‘Princípio da Inversão da Dependência’)
Neste artigo, falaremos sobre a importância de cada princípio e veremos como podemos aplicar os aprendizados do SOLID em aplicativos React.
Antes de começarmos, porém, há uma grande ressalva. Os princípios SOLID foram concebidos e delineados com a linguagem de programação orientada a objetos em mente. Esses princípios e suas explicações dependem muito de conceitos de classes e interfaces, enquanto o JS não apresenta nenhum dos dois. O que muitas vezes pensamos como ‘classes’ em JS são apenas similares de classes simuladas usando seu sistema de protótipo, e as interfaces não fazem parte da linguagem (embora a adição do TypeScript ajude um pouco). Ainda mais, a maneira como escrevemos o código React moderno está longe de ser orientada a objetos - se alguma coisa, parece mais funcional.
A boa notícia, porém, é que os princípios de design de software, como o SOLID, são agnósticos de linguagem e têm um alto nível de abstração, o que significa que, se apertarmos os olhos o suficiente e tomarmos algumas liberdades com a interpretação, poderemos aplicá-los ao nosso código React mais funcional .
Então vamos tomar algumas liberdades.
Princípio da Responsabilidade Única (SRP)
A definição original afirma que ‘toda classe deve ter apenas uma responsabilidade’, ou seja, fazer exatamente uma única coisa. Podemos simplesmente extrapolar a definição para ‘toda função/módulo/componente deve fazer exatamente uma coisa’, mas para entender o que ‘uma coisa’ significa, precisaremos examinar nossos componentes de duas perspectivas diferentes - interna (ou seja, o que o componente faz internamente) e externo (como este componente é usado por outros componentes).
Começaremos olhando por dentro. Para garantir que nossos componentes façam uma coisa internamente, podemos:
- quebrar componentes grandes que fazem muito em componentes menores
- extrair código não relacionado à funcionalidade do componente principal em funções de utilitário separadas
- encapsular a funcionalidade conectada em hooks personalizados
Agora vamos ver como podemos aplicar este princípio. Começaremos considerando o seguinte componente de exemplo que exibe uma lista de usuários ativos:
const ActiveUsersList = () => { const [users, setUsers] = useState([]) useEffect(() => { const loadUsers = async () => { const response = await fetch('/some-api') const data = await response.json() setUsers(data) } loadUsers() }, []) const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); return ( <ul> {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => <li key={user.id}> <img src={user.avatarUrl} /> <p>{user.fullName}</p> <small>{user.role}</small> </li> )} </ul> ) }
Embora este componente seja relativamente curto agora, ele já está fazendo algumas coisas – ele busca dados, filtra, renderiza o próprio componente, bem como itens de lista individuais. Vamos ver como podemos decompô-lo.
Em primeiro lugar, sempre que conectamos os hooks useState e useEffect, é uma boa oportunidade para extraí-los em um hook customizado:
const useUsers = () => { const [users, setUsers] = useState([]) useEffect(() => { const loadUsers = async () => { const response = await fetch('/some-api') const data = await response.json() setUsers(data) } loadUsers() }, []) return { users } } const ActiveUsersList = () => { const { users } = useUsers() const weekAgo = new Date() weekAgo.setDate(weekAgo.getDate() - 7) return ( <ul> {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => <li key={user.id}> <img src={user.avatarUrl} /> <p>{user.fullName}</p> <small>{user.role}</small> </li> )} </ul> ) }
Agora nosso hook useUsers está preocupado com apenas uma coisa – buscar usuários da API. Também tornou nosso componente principal mais legível, não apenas porque ficou mais curto, mas também porque substituímos os hooks estruturais que você precisava para decifrar o propósito por um hook de domínio cujo propósito é imediatamente óbvio pelo nome.
Em seguida, vamos ver o JSX que nosso componente renderiza. Sempre que tivermos um mapeamento de loop sobre um array de objetos, devemos prestar atenção à complexidade do JSX que ele produz para itens individuais do array. Se for um uma única linha que não possui nenhum manipulador de eventos anexado a ele, não há problema em mantê-lo assim, mas para uma marcação mais complexa, pode ser uma boa ideia extraí-lo em um componente separado:
const UserItem = ({ user }) => { return ( <li> <img src={user.avatarUrl} /> <p>{user.fullName}</p> <small>{user.role}</small> </li> ) } const ActiveUsersList = () => { const { users } = useUsers() const weekAgo = new Date() weekAgo.setDate(weekAgo.getDate() - 7) return ( <ul> {users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user => <UserItem key={user.id} user={user} /> )} </ul> ) }
Assim como em uma alteração anterior, tornamos nosso componente principal menor e mais legível extraindo a lógica para renderizar os itens do usuário em um componente separado.
Por fim, temos a lógica para filtrar usuários inativos da lista de todos os usuários que recebemos de uma API. Essa lógica é relativamente isolada e pode ser reutilizada em outras partes do aplicativo, para que possamos extraí-la facilmente em uma função utilitária:
const getOnlyActive = (users) => { const weekAgo = new Date() weekAgo.setDate(weekAgo.getDate() - 7) return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo) } const ActiveUsersList = () => { const { users } = useUsers() return ( <ul> {getOnlyActive(users).map(user => <UserItem key={user.id} user={user} /> )} </ul> ) }
Neste ponto, nosso componente principal é curto e direto o suficiente para que possamos parar de dividi-lo e encerrar o dia. No entanto, se olharmos um pouco mais de perto, veremos que ainda está fazendo mais do que deveria. Atualmente, nosso componente está buscando dados e, em seguida, aplicando filtragem a eles, mas, idealmente, gostaríamos apenas de obter os dados e renderizá-los, sem nenhuma manipulação adicional. Então, como última melhoria, podemos encapsular essa lógica em um novo hook personalizado:
const useActiveUsers = () => { const { users } = useUsers() const activeUsers = useMemo(() => { return getOnlyActive(users) }, [users]) return { activeUsers } } const ActiveUsersList = () => { const { activeUsers } = useActiveUsers() return ( <ul> {activeUsers.map(user => <UserItem key={user.id} user={user} /> )} </ul> ) }
Aqui criamos o hook useActiveUsers para cuidar da lógica de busca e filtragem (também memorizamos dados filtrados para boas medidas), enquanto nosso componente principal é deixado para fazer o mínimo - renderizar os dados que obtém do hook.
Agora, dependendo da nossa interpretação de “uma coisa”, podemos argumentar que o componente ainda está primeiro obtendo os dados e depois os renderizando, o que não é “uma coisa”. Poderíamos dividi-lo ainda mais, chamando um hook em um componente e depois passando o resultado para outro como props, mas encontrei muito poucos casos em que isso é realmente benéfico em aplicativos do mundo real, então vamos perdoar a definição e aceitar “renderização de dados que o componente obtém” como “uma coisa”.
Agora para a perspectiva externa. Nossos componentes nunca existem isoladamente; em vez disso, eles fazem parte de um sistema maior no qual interagem fornecendo sua funcionalidade a outros componentes ou consumindo a funcionalidade fornecida por outros componentes. Como tal, a visão externa do SRP está preocupada com quantas coisas um componente pode ser usado.
Para entender melhor, vamos considerar o exemplo a seguir. Imagine um aplicativo de mensagens (como Telegram ou FB Messenger) e um componente que exiba uma única mensagem. Pode ser tão simples como isso:
onst Message = ({ text }) => { return ( <div> <p>{text}</p> </div> ) }
Se quisermos enviar imagens junto com texto, o componente se torna um pouco mais complexo:
const Message = ({ text, imageUrl }) => { return ( <div> {imageUrl && <img src={imageUrl} />} {text && <p>{text}</p>} </div> ) }
Indo além, podemos adicionar suporte para mensagens de voz também, o que complicará ainda mais o componente:
const Message = ({ text, imageUrl, audioUrl }) => { if (audioUrl) { return ( <div> <audio controls> <source src={audioUrl} /> </audio> </div> ) } return ( <div> {imageUrl && <img src={imageUrl} />} {text && <p>{text}</p>} </div> ) }
Não é difícil imaginar como, com o passar do tempo e ao adicionarmos suporte para vídeos, figurinhas, etc., esse componente continuará crescendo e se transformando em uma bagunça gigantesca. Vamos recapitular o que está acontecendo aqui.
No início, nosso componente está em conformidade com o SRP e faz exatamente uma coisa – renderiza uma mensagem. No entanto, à medida que o aplicativo evolui, adicionamos gradualmente mais e mais funcionalidades a ele. Começamos com pequenas mudanças condicionais na lógica de renderização, depois vamos substituindo completamente a árvore de renderização de forma mais agressiva e, em algum lugar ao longo do caminho, a definição original de “uma coisa” para esse componente se torna muito ampla, muito genérica. Começamos com um componente de propósito único e terminamos com um tipo multifuncional que faz tudo.
A maneira de resolver esse problema é se livrar do componente genérico Message em favor de componentes mais especializados e de propósito único:
const TextMessage = ({ text }) => { return ( <div> <p>{text}</p> </div> ) } const ImageMessage = ({ text, imageUrl }) => { return ( <div> <img src={imageUrl} /> {text && <p>{text}</p>} </div> ) } const AudioMessage = ({ audioUrl }) => { return ( <div> <audio controls> <source src={audioUrl} /> </audio> </div> ) }
A lógica dentro desses componentes é muito diferente uma da outra, então é natural que evoluam separadamente.
Deve-se dizer que problemas como esse sempre surgem gradualmente à medida que o aplicativo cresce. Você quer reutilizar um componente/função existente que faz quase tudo que você precisa, então você coloca uma prop/argumento extra e ajusta a lógica dentro de acordo. Da próxima vez, outra pessoa acaba na mesma situação e, em vez de criar componentes separados e extrair a lógica compartilhada, adiciona outro argumento e outro if. A bola de neve não para de crescer.
Para sair desse loop, da próxima vez que você estiver prestes a ajustar um componente existente para se adequar ao seu caso, considere se está fazendo isso porque faz sentido e tornará o componente mais reutilizável, ou porque você está apenas sendo preguiçoso. Cuidado com o problema dos componentes universais e preste atenção em como você define qual é sua única responsabilidade.
Do lado prático, uma boa indicação de que um componente superou seu propósito original e requer divisão é um monte de instruções if alterando o comportamento do componente. Também se aplica a funções JS simples - se você continuar adicionando argumentos que controlam o fluxo de execução dentro de uma função para produzir resultados diferentes, você pode estar olhando para uma função que está fazendo muito. Outro sinal é um componente com muitos acessórios opcionais. Se você usar esse componente fornecendo um subconjunto distinto de propriedades em diferentes contextos, é provável que você lide com vários componentes disfarçados de um.
Para resumir, o princípio da responsabilidade única se preocupa em manter nossos componentes pequenos e com propósito único. Esses componentes são mais fáceis de raciocinar, mais fáceis de testar e modificar, e é menos provável que introduzamos duplicação de código não intencional.
Princípio Aberto-Fechado – OCP
O OCP afirma que “entidades de software devem ser abertas para extensão, mas fechadas para modificação”. Como nossos componentes e funções do React são entidades de software, não precisamos entortar a definição e, em vez disso, podemos tomá-la em sua forma original.
O princípio aberto-fechado defende a estruturação de nossos componentes de uma maneira que permita que eles sejam estendidos sem alterar seu código-fonte original. Para vê-lo em ação, vamos considerar o seguinte cenário – estamos trabalhando em um aplicativo que usa um componente Header compartilhado em diferentes páginas e, dependendo da página em que estamos, o Header deve renderizar uma interface do usuário ligeiramente diferente:
const Header = () => { const { pathname } = useRouter() return ( <header> <Logo /> <Actions> {pathname === '/dashboard' && <Link to="/events/new">Create event</Link>} {pathname === '/' && <Link to="/dashboard">Go to dashboard</Link>} </Actions> </header> ) } const HomePage = () => ( <> <Header /> <OtherHomeStuff /> </> ) const DashboardPage = () => ( <> <Header /> <OtherDashboardStuff /> </> )
Aqui renderizamos links para diferentes componentes de página dependendo da página atual em que estamos. É fácil perceber que essa implementação é ruim se pensarmos no que acontecerá quando começarmos a adicionar mais páginas. Toda vez que uma nova página é criada, precisaremos voltar ao nosso componente Header e ajustar sua implementação para garantir que ele saiba qual link de ação renderizar. Essa abordagem torna nosso componente Header frágil e fortemente acoplado ao contexto em que é usado, e vai contra o princípio aberto-fechado.
Para corrigir esse problema, podemos usar a composição de componentes. Nosso componente Header não precisa se preocupar com o que ele irá renderizar dentro, e ao invés disso, ele pode delegar essa responsabilidade aos componentes que irão usá-lo usando a propriedade children:
const Header = ({ children }) => ( <header> <Logo /> <Actions> {children} </Actions> </header> ) const HomePage = () => ( <> <Header> <Link to="/dashboard">Go to dashboard</Link> </Header> <OtherHomeStuff /> </> ) const DashboardPage = () => ( <> <Header> <Link to="/events/new">Create event</Link> </Header> <OtherDashboardStuff /> </> )
Com essa abordagem, removemos completamente a lógica da variável que tínhamos dentro do Header e agora podemos usar a composição para colocar lá literalmente o que quisermos sem modificar o próprio componente. Uma boa maneira de pensar sobre isso é que fornecemos um espaço reservado no componente ao qual podemos nos conectar. E também não estamos limitados a um espaço reservado por componente - se precisarmos ter vários pontos de extensão (ou se a prop child já for usada para um propósito diferente), podemos usar qualquer número de props. Se precisarmos passar algum contexto do Header para os componentes que o utilizam, podemos usar o padrão render props. Como você pode ver, a composição pode ser muito poderosa.
Seguindo o princípio aberto-fechado, podemos reduzir o acoplamento entre os componentes e torná-los mais extensíveis e reutilizáveis.
Princípio de Substituição de Liskov – LSP
LSP recomenda projetar objetos de tal forma que “objetos de subtipo devem ser substituíveis por objetos de supertipo”. Em sua definição original, o relacionamento subtipo/supertipo é alcançado por meio de herança de classe, mas não precisa ser assim. Em um sentido mais amplo, herança é simplesmente basear um objeto em outro objeto, mantendo uma implementação semelhante, e isso é algo que fazemos no React com bastante frequência.
Um exemplo muito básico de um relacionamento subtipo/supertipo pode ser demonstrado com um componente construído com a biblioteca styled-components (ou qualquer outra biblioteca CSS-in-JS que use sintaxe semelhante):
import styled from 'styled-components' const Button = (props) => { /* ... */ } const StyledButton = styled(Button)` border: 1px solid black; border-radius: 5px; ` const App = () => { return <StyledButton onClick={handleClick} /> }
No código acima, criamos StyledButton com base no componente Button. Este novo componente StyledButton adiciona algumas classes CSS, mas mantém a implementação do Button original, então, neste contexto, podemos pensar em nosso Button e StyledButton como componentes de supertipo e subtipo.
Além disso, StyledButton também está em conformidade com a interface do componente em que se baseia - ele usa os mesmos props que o próprio Button. Por causa disso, podemos facilmente trocar StyledButton por Button em qualquer lugar em nosso aplicativo sem quebrá-lo ou precisar fazer alterações adicionais. Esse é o benefício que obtemos em conformidade com o princípio de substituição de Liskov.
Aqui está um exemplo mais interessante de como basear um componente em outro:
type Props = InputHTMLAttributes<HTMLInputElement> const Input = (props: Props) => { /* ... */ } const CharCountInput = (props: Props) => { return ( <div> <Input {...props} /> <span>Char count: {props.value.length}</span> </div> ) }
No código acima, usamos um componente básico input para criar uma versão aprimorada dele que também pode exibir o número de caracteres na entrada. Embora adicionemos uma nova lógica a ele, CharCountInput ainda mantém a funcionalidade do componente input original. A interface do componente também permanece inalterada (ambas as entradas usam os mesmos props), então o LSP é observado novamente.
O princípio de substituição de Liskov é particularmente útil no contexto de componentes que compartilham características comuns, como ícones ou entradas - um componente de ícone deve ser trocável por outro ícone, componentes DatePickerInput e AutocompleteInput mais específicos devem ser trocáveis por um componente input mais genérico e assim por diante . No entanto, devemos reconhecer que este princípio não pode e nem sempre deve ser observado. Na maioria das vezes, criamos subcomponentes com o objetivo de adicionar novas funcionalidades que seus supercomponentes não possuem, e que muitas vezes quebrarão a interface do supercomponente. Este é um caso de uso completamente válido, e não devemos tentar calçar o LSP em todos os lugares.
Quanto aos componentes em que o LSP faz sentido, precisamos ter certeza de que não quebramos o princípio desnecessariamente. Vamos dar uma olhada em duas maneiras comuns em que isso pode acontecer.
A primeira envolve cortar uma parte dos props sem motivo:
type Props = { value: string; onChange: () => void } const CustomInput = ({ value, onChange }: Props) => { // ...alguma lógica adicional return <input value={value} onChange={onChange} /> }
Aqui, redefinimos props para CustomInput em vez de usar props que </input> espera. Como resultado, perdemos um grande subconjunto de propriedades que </input> pode levar quebrando sua interface. Para corrigir isso, devemos usar os props que o </input> original espera e passá-los todos usando um operador de propagação:
type Props = InputHTMLAttributes<HTMLInputElement> const CustomInput = (props: Props) => { // ...alguma lógica adicional return <input {...props} /> }
Outra maneira de quebrar o LSP é usar aliases para algumas das propriedades. Isso pode acontecer quando a propriedade que queremos usar tem um conflito de nomenclatura com uma variável local:
type Props = HTMLAttributes<HTMLInputElement> & { onUpdate: (value: string) => void } const CustomInput = ({ onUpdate, ...props }: Props) => { const onChange = (event) => { /// ... alguma lógica onUpdate(event.target.value) } return <input {...props} onChange={onChange} /> }
Para evitar tais conflitos, você deseja ter uma boa convenção de nomenclatura para suas variáveis locais. Por exemplo, é comum ter uma função local handleXXX correspondente para cada propriedade onXXX:
type Props = HTMLAttributes<HTMLInputElement> const CustomInput = ({ onChange, ...props }: Props) => { const handleChange = (event) => { /// ... alguma lógica onChange(event) } return <input {...props} onChange={handleChange} /> }
Princípio da Segregação da Interface – ISP
De acordo com o ISP, “os clientes não devem depender de interfaces que não usam”. Para o bem das aplicações React, vamos traduzi-lo em “componentes não devem depender de props que eles não usam”.
Estamos esticando a definição do ISP aqui, mas não é muito - tanto os props quanto as interfaces podem ser definidos como contratos entre o objeto (componente) e o mundo externo (o contexto em que é usado), para que possamos desenhar paralelos entre os dois. Afinal, não se trata de ser rígido e inflexível com as definições, mas de aplicar princípios genéricos para resolver um problema.
Para ilustrar melhor o problema que o ISP está direcionando, usaremos o TypeScript para o próximo exemplo. Vamos considerar o aplicativo que renderiza uma lista de vídeos:
type Video = { title: string duration: number coverUrl: string } type Props = { items: Array<Video> } const VideoList = ({ items }) => { return ( <ul> {items.map(item => <Thumbnail key={item.title} video={item} /> )} </ul> ) }
Nosso componente Thumbnail que ele usa para cada item pode ser algo assim:
type Props = { video: Video } const Thumbnail = ({ video }: Props) => { return <img src={video.coverUrl} /> }
O componente Thumbnail é bem pequeno e simples, mas tem um problema – ele espera que um objeto de vídeo completo seja passado como props, enquanto efetivamente usa apenas uma de suas propriedades.
Para ver por que isso é problemático, imagine que, além dos vídeos, decidamos também exibir miniaturas para transmissões ao vivo, com os dois tipos de recursos de mídia misturados na mesma lista.
Apresentaremos um novo tipo que define um objeto de transmissão ao vivo:
type LiveStream = { name: string previewUrl: string }
E este é o nosso componente VideoList atualizado:
type Props = { items: Array<Video | LiveStream> } const VideoList = ({ items }) => { return ( <ul> {items.map(item => { if ('coverUrl' in item) { // é um vídeo return <Thumbnail video={item} /> } else { // é um stream ao vivo, mas o que podemos fazer com isso? } })} </ul> ) }
Como você pode ver, aqui temos um problema. Podemos distinguir facilmente entre objetos de vídeo e transmissão ao vivo, mas não podemos passar o último para o componente Thumbnail porque Video e LiveStream são incompatíveis. Primeiro, eles têm tipos diferentes, então o TypeScript reclamaria imediatamente. Em segundo lugar, eles contêm o URL da miniatura em diferentes propriedades – o objeto de vídeo o chama de coverUrl, o objeto de transmissão ao vivo o chama de previewUrl. Esse é o cerne do problema de ter componentes que dependem de mais props do que realmente precisam – eles se tornam menos reutilizáveis. Então vamos consertar.
Vamos refatorar nosso componente Thumbnail para garantir que ele dependa apenas dos props necessários:
type Props = { coverUrl: string } const Thumbnail = ({ coverUrl }: Props) => { return <img src={coverUrl} /> }
Com essa alteração, agora podemos usá-la para renderizar miniaturas de vídeos e transmissões ao vivo:
type Props = { items: Array<Video | LiveStream> } const VideoList = ({ items }) => { return ( <ul> {items.map(item => { if ('coverUrl' in item) { type Props = { items: Array<Video | LiveStream> } const VideoList = ({ items }) => { return ( <ul> {items.map(item => { if ('coverUrl' in item) { // é um vídeo return <Thumbnail coverUrl={item.coverUrl} /> } else { // é um stream ao vivo return <Thumbnail coverUrl={item.previewUrl} /> } })} </ul> ) } } })} </ul> ) }
O princípio de segregação de interfaces preconiza a minimização das dependências entre os componentes do sistema, tornando-os menos acoplados e, portanto, mais reutilizáveis.
Princípio da Inversão da Dependência – DIP
O princípio da inversão de dependência afirma que “deve-se depender de abstrações, não de concreções”. Em outras palavras, um componente não deve depender diretamente de outro componente, mas ambos devem depender de alguma abstração comum. Aqui, “componente” refere-se a qualquer parte do nosso aplicativo, seja um componente React, uma função utilitária, um módulo ou uma biblioteca de terceiros. Esse princípio pode ser difícil de entender em abstrato, então vamos direto para um exemplo.
Abaixo temos o componente LoginForm que envia as credenciais do usuário para alguma API quando o formulário é enviado:
import api from '~/common/api' const LoginForm = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const handleSubmit = async (evt) => { evt.preventDefault() await api.login(email, password) } return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} /> <button type="submit">Log in</button> </form> ) }
Neste trecho de código, nosso componente LoginForm faz referência direta ao módulo api, portanto, há um acoplamento estreito entre eles. Isso é ruim porque essa dependência torna mais desafiador fazer alterações em nosso código, pois uma alteração em um componente afetará outros componentes. O princípio de inversão de dependência defende a quebra desse acoplamento, então vamos ver como podemos conseguir isso.
Primeiro, vamos remover a referência direta ao módulo api de dentro do LoginForm e, em vez disso, permitir que a funcionalidade necessária seja injetada por meio de props:
type Props = { onSubmit: (email: string, password: string) => Promise<void> } const LoginForm = ({ onSubmit }: Props) => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const handleSubmit = async (evt) => { evt.preventDefault() await onSubmit(email, password) } return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} /> <button type="submit">Log in</button> </form> ) }
Com essa mudança, nosso componente LoginForm não depende mais do módulo api. A lógica para enviar credenciais para a API é abstraída por meio do retorno de chamada onSubmit e agora é responsabilidade do componente pai fornecer a implementação concreta dessa lógica.
Para isso, criaremos uma versão conectada do LoginForm que delegará a lógica de envio de formulários ao módulo api:
import api from '~/common/api' const ConnectedLoginForm = () => { const handleSubmit = async (email, password) => { await api.login(email, password) } return ( <LoginForm onSubmit={handleSubmit} /> ) }
O componente ConnectedLoginForm serve como uma cola entre api e o LoginForm, enquanto eles permanecem totalmente independentes um do outro. Podemos iterar neles e testá-los isoladamente sem nos preocupar em quebrar peças móveis dependentes, pois não há nenhuma. E desde que o LoginForm e api sigam a abstração comum acordada, o aplicativo como um todo continuará funcionando conforme o esperado.
No passado, essa abordagem de criar componentes de apresentação ‘burros’ e depois injetar lógica neles também era usada por muitas bibliotecas de terceiros. O exemplo mais conhecido disso é o Redux, que vincularia props de retorno de chamada nos componentes para funções dispatch usando o componente de alta ordem connect (HOC). Com a introdução de hooks esta abordagem tornou-se um pouco menos relevante, mas injetar lógica via HOCs ainda tem utilidade em aplicações React.
Para concluir, o princípio de inversão de dependência visa minimizar o acoplamento entre os diferentes componentes da aplicação. Como você provavelmente notou, minimizar é um tema recorrente em todos os princípios do SOLID - , desde minimizar o escopo de responsabilidades para componentes individuais até minimizar o conhecimento de componentes cruzados e as dependências entre eles.
Conclusão
Apesar de nascer de problemas do mundo OOP, os princípios SOLID têm sua aplicação muito além disso. Neste artigo, vimos como, tendo alguma flexibilidade com as interpretações desses princípios, conseguimos aplicá-los ao nosso código React e torná-lo mais sustentável e robusto.
É importante lembrar, porém, que ser dogmático e seguir religiosamente esses princípios pode ser prejudicial e levar a um código com engenharia excessiva, portanto, devemos aprender a reconhecer quando a decomposição ou desacoplamento de componentes introduz complexidade para pouco ou nenhum benefício.”
Publicado originalmente como “Applying SOLID principles in React” em 12 de julho de 2022. Traduzido e republicado com autorização do autor.