DEV Community

Marcus Maia
Marcus Maia

Posted on

O Poder do SOLID: Desmistificando e construindo componentes com React e Typescript

Você iniciou aquele MVP e a complexidade do projeto começou a te assustar? A manutenção dos componentes se tornou um emaranhado de 'if's ternários' para todo lado? Fica tranquilo que o SOLID pode te ajudar a diminuir a complexidade do seu projeto e transformar o seu código!

O Que é SOLID?

SOLID é o acrônimo para cinco princípios de design de software que visam tornar o código mais compreensível, flexível e de fácil manutenção. Embora seja amplamente utilizado no desenvolvimento backend e em linguagens como Java, C# ou outras que seguem o paradigma de Programação Orientada a Objetos (POO), sua aplicação é igualmente vital para o frontend moderno, especialmente com frameworks como React, Angular e Vue, onde modularidade e reusabilidade são cruciais.

No nosso exemplo de hoje, vamos explorar como o React e o TypeScript podem te ajudar a desmistificar e aplicar os princípios SOLID na prática. Vamos lá!

Single Responsibility Principle (SRP): Princípio da Reponsabilidade Única

Como o nome já deixa explícito, é o princípio em que uma única função, classe ou, no nosso exemplo, componente, deve ter uma única responsabilidade.

// Exemplo Ruim ❌
// Este componente possui duas responsabilidades: exibir o perfil E um formulário de edição.
<>
    <UserProfileDisplayAndForm />
</>

Enter fullscreen mode Exit fullscreen mode

Esse componente está com mais de uma responsabilidade, e isso faz com que a complexidade aumente exponencialmente caso venha a se ter mais “features” no futuro que envolva a parte do perfil do usuário.

Para deixar cada qual com sua responsabilidade, precisamos segregar cada responsabilidade ao seu devido componente.

// Bom exemplo ✅
// Cada componente agora tem uma única responsabilidade.
<>
  {/* UserProfileDisplay - Responsável apenas por exibir o perfil do usuário */}
  <UserProfileDisplay />
  {/* UserForm - Responsável apenas pelo formulário de edição do usuário */}
  <UserForm />
</>
Enter fullscreen mode Exit fullscreen mode

Open/Closed Principle (OCP): Princípio aberto e fechado

O princípio aberto/fechado nos diz que devemos estar abertos para extensão e fechados para modificações.

No nosso exemplo, o botão tem muitos motivos para mudar. Se quisermos adicionar um estado diferente, como um estilo, precisamos modificar sua estrutura interna, o que aumentará sua complexidade.

// Exemplo Ruim ❌
export const Button({ type, children }) {
    if (type === "primary") { /* renderiza com estilo primary */ }
    else if (type === "secondary") { /* renderiza com estilo secondary */ }
    // Se precisarmos adicionar um novo tipo de botão (ex: "danger"), teríamos que modificar este componente
    // e adicionar outro 'else if', aumentando a complexidade.
    // ... retorna um estilo genérico
}

Enter fullscreen mode Exit fullscreen mode

Se modificarmos a estrutura para se adaptar ao conceito aberto/fechado, o código fica assim:

// Bom exemplo ✅
// O componente Button está aberto para extensão (novos tipos de estilo)
// e fechado para modificação (não precisamos alterar sua estrutura interna para novos tipos).
export const Button({ type, children }: { type: string, children: ReactNode | string }) {
    return <button className={`bg-${type}-500 text-white`}>{children}</button>
}

// Ou você pode utilizar também styled-components para estender o estilo
// Para um novo tipo, você cria um novo componente estilizado, sem alterar o Button base.
// Exemplo com styled-components:
const PrimaryButton = styled(Button)`
    background-color: blue;
`;
const SecondaryButton = styled(Button)`
  background-color: green;
`;

Enter fullscreen mode Exit fullscreen mode

Liskov Substitution Principle (LSP): Princípio da Substituição de Liskov

Se S é um subtipo de T, então objetos do tipo T devem poder ser substituídos por objetos do tipo S sem alterar a corretude do programa. Em outras palavras, subclasses (ou componentes que se comportam como 'tipos') devem ser substituíveis por suas classes/componentes base sem quebrar o sistema.

Imagine que temos um componente ListItem e subtipos ProductListItem e ArticleListItem.

Cenário Ruim:
Se ProductListItem e ArticleListItem herdam de ListItem, mas ProductListItem espera uma prop price que ArticleListItem não tem, e o ListItem base não lida com essa ausência.

// Exemplo Ruim ❌

// Componente base ListItem que (erroneamente) assume a existência de 'price'
function ListItem({ item }) {
    // Este componente espera que 'item' SEMPRE tenha uma propriedade 'price'.
    // Se um item sem 'price' for passado, o componente pode quebrar ou exibir 'undefined'.
    return <div>{item.name} - {item.price}</div>; // Bug se item.price não existe
}

// Subtipo ArticleListItem, que não possui a propriedade 'price' esperada por ListItem
function ArticleListItem({ item }) {
    // ArticleListItem está usando ListItem, mas seus itens não possuem 'price'.
    // Isso viola o LSP porque ArticleListItem não pode substituir ListItem sem causar um erro.
    return <ListItem item={item} />; // Item de artigo não tem preço, vai quebrar!
}

// Subtipo ProductListItem, que possui a propriedade 'price'
function ProductListItem({ item }) {
    // Embora ProductListItem funcione com ListItem, a dependência implícita de 'price'
    // no ListItem base limita a substituibilidade.
    return <ListItem item={item} />; // Este funcionaria, mas o base é problemático.
}

// Uso (problema aqui!)
<ArticleListItem item={myArticle} /> // Isso causaria um erro de runtime
Enter fullscreen mode Exit fullscreen mode

Cenário Bom:

Garantir que o componente base ou a interface definam um comportamento esperado, e os componentes "derivados" (subtipos) o respeitem.
Ou seja, se você tem uma lista que espera renderizar ListItem, qualquer "tipo" de item que você passar (ProductListItem ou ArticleListItem) deve ser renderizado corretamente sem causar erros no componente pai. Isso geralmente é garantido por uma boa tipagem (TypeScript) e tipos bem definidos.

// Bom exemplo ✅

// Interface base que define o contrato mínimo para qualquer item de lista.
// Todos os subtipos devem, no mínimo, ter 'title' e 'author'.
interface ListItemBaseProps {
    title: string;
    author: string;
    // Opcionalmente, pode-se adicionar uma propriedade para o tipo específico do item,
    // ou usar um discriminante para unir diferentes tipos.
}

// Componente base ListItem que renderiza informações genéricas.
function ListItem({ item }: { item: ListItemBaseProps }) {
    return (
        <div>
            <h1>{item.title}</h1>
            <p>Por {item.author}</p>
        </div>
    );
}

// Interface para um item de artigo, estendendo a base e adicionando propriedades específicas.
interface ArticleItemProps extends ListItemBaseProps {
    id: string;
    summary: string;
    // NENHUMA propriedade que não seja da base é obrigatória aqui,
    // garantindo que ArticleItem possa substituir ListItemBaseProps.
}

// Componente ArticleListItem, que mantém o contrato base e estende o comportamento
function ArticleListItem({ item }: { item: ArticleItemProps }) {
    return (
        <div>
            <h3>{item.title}</h3>
            <p>{item.summary}</p>
            <small>Por {item.author}</small>
        </div>
    );
}

// Interface para um item de produto, estendendo a base e adicionando propriedades específicas.
interface ProductItemProps extends ListItemBaseProps {
    id: string;
    price: number;
    currency: string;
    // NENHUMA propriedade que não seja da base é obrigatória aqui.
}

// Componente ProductListItem, que mantém o contrato base e estende o comportamento
function ProductListItem({ item }: { item: ProductItemProps }) {
    return (
        <div>
            <h2>{item.title}</h2>
            <p>Preço: {item.currency} {item.price.toFixed(2)}</p>
            <small>Por {item.author}</small>
        </div>
    );
}

// No uso real, você poderia ter uma lógica para escolher qual componente renderizar:
function SpecificList({ items }: { items: (ArticleItemProps | ProductItemProps)[] }) {
    return (
        <div>
            {items.map((item, index) => {
                if ('summary' in item) {
                    return <ArticleListItem key={index} item={item as ArticleItemProps} />;
                } else if ('price' in item) {
                    return <ProductListItem key={index} item={item as ProductItemProps} />;
                }
                return <ListItem key={index} item={item as ListItemBaseProps} />;
            })}
        </div>
    );
}

// Exemplo de como você usaria:
const articles = [
    { title: "SOLID em React", author: "Dev João", id: "1", summary: "Um guia completo..." },
    { title: "Performance com Hooks", author: "Dev Maria", id: "2", summary: "Otimizando seus componentes..." },
];

const products = [
    { title: "Monitor Gamer", author: "Tech XYZ", id: "P1", price: 1200, currency: "BRL" },
    { title: "Teclado Mecânico", author: "ABC Peripherals", id: "P2", price: 450, currency: "BRL" },
];

<MyList items={articles} /> // Funciona, mas renderizaria apenas o título e autor
<MyList items={products} /> // Funciona, mas renderizaria apenas o título e autor

// Usando a SpecificList para demonstrar a substituibilidade e especialização
<SpecificList items={[...articles, ...products]} /> // Agora ambos os tipos funcionam lado a lado
Enter fullscreen mode Exit fullscreen mode

Dessa forma, ArticleListItem e ProductListItem (os subtipos) podem ser usados onde ListItem (o tipo base com ListItemBaseProps) é esperado, sem quebrar o comportamento, pois todos respeitam o contrato mínimo e adicionam suas próprias especializações de forma segura.

Interface Segregation Principle (ISP): Princípio da Segregação de Interfaces

Esse é um caso que já vi em muitos cenários reais do dia a dia: ter interfaces gigantescas fazendo muita coisa, que poderiam ser segregadas para obtermos uma coerência no componente que estamos criando ou refatorando.

// Exemplo Ruim ❌
// Interface UserProps com muitas propriedades que nem sempre são necessárias para todos os componentes.
interface UserProps {
    id: string;
    name: string;
    email: string;
    address: string;
    phone: string;
    // ... e muito mais propriedades que este componente não usa
}

// O componente UserDetails depende de uma interface UserProps, mas utiliza apenas 'name' e 'email'.
// Isso força o componente a ter uma dependência maior do que o necessário.
export function UserDetails({ user }: { user: UserProps }) {
    return (
        <div>
            <p>Nome: {user.name}</p>
            <p>Email: {user.email}</p>
        </div>
    );
}

Enter fullscreen mode Exit fullscreen mode
// Bom exemplo ✅
// Interface UserSummaryProps, segregada para conter apenas as propriedades essenciais
// para exibir um resumo do usuário.
interface UserSummaryProps {
    name: string;
    email: string;
}

export function UserDetails({ name, email }: UserSummaryProps) {
    return (
        <div>
            <p>Nome: {name}</p>
            <p>Email: {email}</p>
        </div>
    );
}

// O componente agora está mais limpo e com sua interface mais segregada.
// Ele depende apenas das informações que realmente utiliza.

Enter fullscreen mode Exit fullscreen mode

Dessa forma, nosso componente fica mais robusto a mudanças em dados que ele não utiliza e é mais fácil de estender.

Dependency Inversion Principle (DIP): Princípio da Inversão de Dependência

Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Abstrações não devem depender de detalhes; detalhes devem depender de abstrações. Isso quer dizer que você não deve depender de implementações concretas.

Se você se sentiu confuso, respira e vem comigo para um exemplo prático!

Imagina que você tem uma lista de produtos para pegar da API:

// Exemplo Ruim ❌
// ProductList.tsx
import { ApiServiceImpl } from './apiService'; // Dependência concreta diretamente importada

function ProductList() {
    const [products, setProducts] = useState([]);
    useEffect(() => {
            // Se o método de busca de produtos mudar (ex: de REST para GraphQL),
        // este componente ProductList precisaria ser modificado.
        ApiServiceImpl.getProducts().then(setProducts);
    }, []);
    // ...
}

Enter fullscreen mode Exit fullscreen mode

Se você precisar mudar a forma como os dados são buscados (ex: mudou de REST para GraphQL, aí a situação complicaria, não é mesmo?), você teria que modificar toda a estrutura do seu ProductList.

Agora o ProductList vai depender de uma abstração (uma interface de serviço, ou uma função que recebe o método de busca). A implementação concreta é injetada através do componente.

// Bom exemplo ✅
// apiService.ts (abstração - define o contrato do serviço)
interface ProductService {
    getProducts(): Promise<Product[]>;
}
// apiServiceImpl.ts (implementação concreta - detalhe de como os produtos são buscados, ex: REST ou GraphQL)
class ApiServiceImpl implements ProductService {
    async getProducts() { /* busca REST ou GraphQL */ return [] }
}

// mockServiceImpl.ts (implementação concreta para testes - detalhe de como os produtos são mockados)
class MockServiceImpl implements ProductService {
    async getProducts() { return [{ id: 1, name: 'Mock Product' }]; }
}

// ProductList.tsx (depende da abstração ProductService, não da implementação concreta)
function ProductList({ productService }: { productService: ProductService }) {
    const [products, setProducts] = useState([]);
    useEffect(() => {
        productService.getProducts().then(setProducts);
    }, [productService]);
    // ... restante do componente
}

// Onde você instancia vai ficar dessa maneira:
// O ProductList recebe a implementação concreta que precisa, invertendo a dependência.
<ProductList productService={new ApiServiceImpl()} />
// Ou para testes:
<ProductList productService={new MockServiceImpl()} />

Enter fullscreen mode Exit fullscreen mode

Agora não precisamos saber o detalhe de como os produtos são buscados, apenas que existe um productService que tem o método getProducts(). Isso faz com que o código seja muito mais testável e flexível a mudanças.

Por Que SOLID é Importantíssimo para a Sua Carreira?

Agora que você já possui uma ideia do que são os princípios, vamos ao ponto crucial: por que são tão importantes pra VOCÊ programador frontend júnior, e para sua carreira?

Código mais limpo e sustentável:

  • Menos Bugs: Componentes com responsabilidades únicas e bem separados são fáceis de testar e menos propensos a introduzir bugs.
  • Fácil de entender: Um código SOLID é como um quebra-cabeças com peças bem definidas. Qualquer um consegue entender a função de cada parte. Isso é crucial em equipes.
  • Menos Dívida Técnica: Você escreve código que envelhece bem, reduzindo o tempo e o custo de manutenção no futuro.

Acelera o Desenvolvimento (a longo prazo):

  • Pode parecer que aplicar SOLID no início leva mais tempo, mas é um investimento. Módulos bem desenhados são fáceis de reutilizar e estender, o que acelera significativamente o desenvolvimento de novas funcionalidades e a adaptação a mudanças de requisitos.

Flexibilidade e Adaptabilidade:

  • O mundo frontend muda constantemente. Frameworks, bibliotecas e requisitos evoluem. Código SOLID é mais maleável; ele permite que você troque partes do sistema (ex: uma biblioteca de gerenciamento de estado) ou adicione novas funcionalidades sem reescrever tudo do zero.

Colaboração em Equipe:

  • Em equipes grandes, muitos desenvolvedores trabalham no mesmo codebase. Princípios SOLID garantem que o código seja previsível, com responsabilidades claras, minimizando conflitos de merge e facilitando a revisão de código. Você será um colega de equipe mais produtivo.

Testabilidade:

  • Um dos maiores benefícios do SOLID é que ele força você a escrever código que é inerentemente mais fácil de testar. Componentes com responsabilidade única e dependências invertidas são ideais para testes unitários e de integração, o que é fundamental para a qualidade do software.

Crescimento Profissional e Reconhecimento:

  • Diferencial no Mercado: Desenvolvedores que escrevem código limpo, testável e sustentável são altamente valorizados. Em entrevistas, conseguir discutir e aplicar esses princípios demonstra um nível de maturidade que vai além do básico.
  • Melhora no Code Review: Quando você entende SOLID, seus "code reviews" serão mais construtivos e você absorverá melhor os feedbacks.
  • Base para Outros Conceitos: SOLID é a base para outros conceitos avançados de arquitetura de software, como Arquitetura Limpa (Clean Architecture) e Domain-Driven Design (DDD). Dominá-lo abrirá portas para funções mais sênior e de liderança técnica.

Resolução de Problemas Complexos:

  • Projetos frontend modernos são complexos. Aplicar SOLID te dá um "toolkit mental" para decompor problemas grandes em partes menores e gerenciáveis, facilitando a resolução de desafios.

Como Começar a Aplicar?

  • Comece pequeno: não tente refatorar todo o seu projeto de uma vez só. Comece aplicando os princípios em novos componentes um de cada vez. Assim que dominar um princípio, vá para o outro; o importante é o entendimento do assunto e sua aplicabilidade.
  • Pratique Testes: A escrita de testes por si só é importantíssima desde o começo, e quando você começa a utilizar o SOLID, percebe que é muito mais fácil testar algo com boas práticas do que um componente que não respeita os princípios (como o da Responsabilidade Única ou a Inversão de Dependência).
  • Leia e Discuta: Continue lendo sobre SOLID, assista a vídeos, discuta com pessoas e pratique. Peça feedback com amigos e colegas sobre como melhorar seu código em relação a esses princípios.

Lembre-se: SOLID é agnóstico de linguagem ou framework, ele serve apenas como um guia para nós, desenvolvedores. E outra, não é um conjunto a ser religiosamente seguido, beleza? Pense neles como um conjunto de boas práticas a ser seguido, e com o tempo e prática vão se tornar comum para você. Invista nisso e em sua carreira.

Então é isso, Dev! Se você ficou até aqui, meu muito obrigado e espero que este artigo te ajude de alguma forma!

Top comments (0)