DEV Community

Marcus Maia
Marcus Maia

Posted on

2

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!

AWS GenAI LIVE image

Real challenges. Real solutions. Real talk.

From technical discussions to philosophical debates, AWS and AWS Partners examine the impact and evolution of gen AI.

Learn more

Top comments (0)

Tiger Data image

🐯 🚀 Timescale is now TigerData: Building the Modern PostgreSQL for the Analytical and Agentic Era

We’ve quietly evolved from a time-series database into the modern PostgreSQL for today’s and tomorrow’s computing, built for performance, scale, and the agentic future.

So we’re changing our name: from Timescale to TigerData. Not to change who we are, but to reflect who we’ve become. TigerData is bold, fast, and built to power the next era of software.

Read more

👋 Kindness is contagious

Explore this insightful piece, celebrated by the caring DEV Community. Programmers from all walks of life are invited to contribute and expand our shared wisdom.

A simple "thank you" can make someone’s day—leave your kudos in the comments below!

On DEV, spreading knowledge paves the way and fortifies our camaraderie. Found this helpful? A brief note of appreciation to the author truly matters.

Let’s Go!