Tag: clean code

  • Princípios SOLID no React: Guia Prático com Exemplos Reais

    Princípios SOLID no React: Guia Prático com Exemplos Reais

    Princípios SOLID no React transformam a forma como você estrutura componentes, tornando seu código mais escalável, testável e de fácil manutenção. Se você desenvolve aplicações React, dominar os princípios SOLID pode elevar significativamente a qualidade do seu código, reduzindo bugs e facilitando colaboração em equipe.

    O Que é SOLID no Desenvolvimento de Software?

    SOLID é um acrônimo que representa cinco princípios fundamentais de design de software orientado a objetos. Esses princípios foram introduzidos por Robert C. Martin (Uncle Bob). Embora originalmente pensados para linguagens orientadas a objetos como Java, eles se aplicam perfeitamente ao React moderno. Cada letra representa um princípio específico. Primeiro, S representa Single Responsibility (Responsabilidade Única). Segundo, O significa Open/Closed (Aberto/Fechado). Terceiro, L indica Liskov Substitution (Substituição de Liskov). Quarto, I corresponde a Interface Segregation (Segregação de Interface). Por fim, D representa Dependency Inversion (Inversão de Dependência).

    Esses princípios não são regras rígidas. Na verdade, são diretrizes que ajudam a criar código mais limpo e manutenível. Portanto, pense neles como ferramentas no seu toolkit de desenvolvimento. Além disso, aplicar SOLID no React traz benefícios concretos. Primeiro, componentes ficam mais reutilizáveis. Segundo, testes se tornam mais simples. Terceiro, bugs são mais fáceis de identificar. Por fim, novas funcionalidades são adicionadas sem quebrar código existente. Consequentemente, sua base de código se torna muito mais sustentável ao longo do tempo.

    Single Responsibility Principle no React

    O Single Responsibility Principle (SRP) estabelece que cada componente deve ter apenas uma razão para mudar. Em outras palavras, um componente deve fazer apenas uma coisa e fazer bem feito. Assim como um chef de cozinha não é também o garçom e o caixa, seus componentes React não devem acumular múltiplas responsabilidades. Portanto, quando um componente mistura lógica de negócio, manipulação de estado, chamadas de API e renderização visual, ele viola o SRP. Dessa forma, manutenção se torna difícil e bugs se multiplicam.

    Exemplo prático – Componente de usuário com responsabilidade única:

    // ❌ Violando SRP - componente faz tudo
    function UserProfile() {
      const [user, setUser] = useState(null);
      const [loading, setLoading] = useState(false);
    
      useEffect(() => {
        setLoading(true);
        fetch('/api/user')
          .then(res => res.json())
          .then(data => setUser(data))
          .finally(() => setLoading(false));
      }, []);
    
      const formatDate = (date) => {
        return new Date(date).toLocaleDateString('pt-BR');
      };
    
      if (loading) return <div>Carregando...</div>;
    
      return (
        <div className="profile">
          <img src={user.avatar} />
          <h1>{user.name}</h1>
          <p>Membro desde {formatDate(user.createdAt)}</p>
        </div>
      );
    }
    
    // ✅ Seguindo SRP - responsabilidades separadas
    // Hook customizado cuida da lógica de dados
    function useUserData() {
      const [user, setUser] = useState(null);
      const [loading, setLoading] = useState(false);
    
      useEffect(() => {
        setLoading(true);
        fetch('/api/user')
          .then(res => res.json())
          .then(data => setUser(data))
          .finally(() => setLoading(false));
      }, []);
    
      return { user, loading };
    }
    
    // Utility cuida da formatação
    const formatDate = (date) => {
      return new Date(date).toLocaleDateString('pt-BR');
    };
    
    // Componente cuida apenas da apresentação
    function UserProfile() {
      const { user, loading } = useUserData();
    
      if (loading) return <LoadingSpinner />;
    
      return (
        <div className="profile">
          <UserAvatar src={user.avatar} />
          <UserInfo name={user.name} memberSince={formatDate(user.createdAt)} />
        </div>
      );
    }

    Neste exemplo, separamos claramente as responsabilidades. Primeiro, o hook useUserData cuida exclusivamente da lógica de busca de dados. Segundo, a função formatDate é responsável apenas pela formatação. Por fim, o componente UserProfile se concentra unicamente na apresentação visual. Portanto, cada parte tem uma única razão para mudar. Consequentemente, se precisarmos alterar como os dados são buscados, modificamos apenas o hook. Assim, o código fica mais organizado e testável.

    Open/Closed Principle no React

    O Open/Closed Principle (OCP) afirma que componentes devem estar abertos para extensão, mas fechados para modificação. Em outras palavras, você deve conseguir adicionar novas funcionalidades sem alterar o código existente. Portanto, componentes bem projetados aceitam configurações e comportamentos através de props. Assim, você estende funcionalidades sem modificar a implementação original. Dessa forma, reduz o risco de quebrar código que já funciona.

    Exemplo prático – Botão extensível com variantes:

    // ❌ Violando OCP - precisa modificar o componente para adicionar tipos
    function Button({ type, children }) {
      if (type === 'primary') {
        return <button className="bg-blue-500">{children}</button>;
      }
      if (type === 'secondary') {
        return <button className="bg-gray-500">{children}</button>;
      }
      if (type === 'danger') {
        return <button className="bg-red-500">{children}</button>;
      }
      // Precisaria adicionar mais ifs para novos tipos
      return <button>{children}</button>;
    }
    
    // ✅ Seguindo OCP - extensível através de props
    interface ButtonProps {
      variant?: 'primary' | 'secondary' | 'danger';
      size?: 'sm' | 'md' | 'lg';
      className?: string;
      children: React.ReactNode;
    }
    
    const buttonVariants = {
      primary: 'bg-blue-500 hover:bg-blue-600',
      secondary: 'bg-gray-500 hover:bg-gray-600',
      danger: 'bg-red-500 hover:bg-red-600'
    };
    
    const buttonSizes = {
      sm: 'px-3 py-1 text-sm',
      md: 'px-4 py-2',
      lg: 'px-6 py-3 text-lg'
    };
    
    function Button({
      variant = 'primary',
      size = 'md',
      className = '',
      children
    }: ButtonProps) {
      const baseClasses = 'rounded font-medium transition';
      const variantClasses = buttonVariants[variant];
      const sizeClasses = buttonSizes[size];
    
      return (
        <button className={`${baseClasses} ${variantClasses} ${sizeClasses} ${className}`}>
          {children}
        </button>
      );
    }
    
    // Agora é fácil adicionar novas variantes sem modificar o componente
    // Basta adicionar ao objeto buttonVariants

    Neste exemplo, o componente Button está fechado para modificação mas aberto para extensão. Primeiro, todas as variantes são definidas em objetos de configuração separados. Segundo, adicionar uma nova variante não requer alterar a lógica do componente. Além disso, você pode passar classes customizadas via prop className. Portanto, o componente é extremamente flexível sem precisar ser modificado. Consequentemente, reduzimos bugs e facilitamos manutenção.

    Liskov Substitution Principle no React

    O Liskov Substitution Principle (LSP) determina que componentes derivados devem ser substituíveis por seus componentes base sem alterar o comportamento esperado. Em termos práticos no React, isso significa que componentes especializados devem manter a mesma interface e contrato que seus componentes genéricos. Portanto, se você tem um componente Button base, qualquer PrimaryButton ou SecondaryButton deve funcionar exatamente como um Button. Dessa forma, você garante consistência e previsibilidade no comportamento dos componentes.

    Exemplo prático – Hierarquia de botões consistente:

    // ❌ Violando LSP - botão especializado muda comportamento
    function Button({ onClick, children }) {
      return <button onClick={onClick}>{children}</button>;
    }
    
    function SubmitButton({ onClick, children }) {
      // Adiciona comportamento inesperado que quebra a substituição
      const handleClick = () => {
        console.log('Enviando formulário...'); // Side effect inesperado
        onClick();
      };
    
      return <button type="submit" onClick={handleClick}>{children}</button>;
    }
    
    // ✅ Seguindo LSP - comportamento consistente
    interface ButtonProps {
      onClick?: () => void;
      children: React.ReactNode;
      type?: 'button' | 'submit' | 'reset';
      disabled?: boolean;
    }
    
    function Button({ onClick, children, type = 'button', disabled = false }: ButtonProps) {
      return (
        <button type={type} onClick={onClick} disabled={disabled}>
          {children}
        </button>
      );
    }
    
    // Especialização mantém o contrato
    function SubmitButton({ onClick, children, disabled = false }: Omit<ButtonProps, 'type'>) {
      return (
        <Button type="submit" onClick={onClick} disabled={disabled}>
          {children}
        </Button>
      );
    }
    
    // Ambos podem ser usados intercambiavelmente
    function Form() {
      const handleSubmit = () => console.log('Formulário enviado');
    
      return (
        <>
          <Button onClick={handleSubmit}>Salvar</Button>
          <SubmitButton onClick={handleSubmit}>Salvar</SubmitButton>
        </>
      );
    }

    Neste exemplo, o SubmitButton é uma especialização válida de Button. Primeiro, ambos compartilham a mesma interface de props. Segundo, não há side effects inesperados ou comportamentos que quebram expectativas. Além disso, você pode substituir um pelo outro sem problemas. Portanto, o código respeita o LSP perfeitamente. Consequentemente, componentes especializados são previsíveis e confiáveis. Assim, desenvolvedores podem usar qualquer variante com confiança.

    Interface Segregation Principle no React

    O Interface Segregation Principle (ISP) estabelece que componentes não devem ser forçados a depender de props que não utilizam. Em outras palavras, é melhor ter múltiplas interfaces específicas do que uma única interface genérica sobrecarregada. Portanto, componentes devem receber apenas as props que realmente precisam. Além disso, isso facilita testes, reutilização e manutenção. Consequentemente, você evita componentes acoplados a dados desnecessários.

    Exemplo prático – Props específicas em vez de objeto completo:

    // ❌ Violando ISP - componente recebe objeto inteiro mas usa apenas parte
    interface User {
      id: number;
      name: string;
      email: string;
      avatar: string;
      bio: string;
      followers: number;
      following: number;
      posts: Post[];
      settings: UserSettings;
    }
    
    function UserAvatar({ user }: { user: User }) {
      // Usa apenas avatar e name, mas recebe User completo
      return (
        <div>
          <img src={user.avatar} alt={user.name} />
          <span>{user.name}</span>
        </div>
      );
    }
    
    // ✅ Seguindo ISP - recebe apenas props necessárias
    interface AvatarProps {
      src: string;
      alt: string;
      size?: 'sm' | 'md' | 'lg';
    }
    
    function Avatar({ src, alt, size = 'md' }: AvatarProps) {
      const sizeClasses = {
        sm: 'w-8 h-8',
        md: 'w-12 h-12',
        lg: 'w-16 h-16'
      };
    
      return (
        <img
          src={src}
          alt={alt}
          className={`rounded-full ${sizeClasses[size]}`}
        />
      );
    }
    
    // Uso específico
    function UserCard({ user }: { user: User }) {
      return (
        <div>
          <Avatar src={user.avatar} alt={user.name} size="md" />
          <h3>{user.name}</h3>
        </div>
      );
    }

    Neste exemplo, o componente Avatar recebe apenas as props que realmente precisa. Primeiro, em vez de receber o objeto User completo, ele aceita apenas src, alt e size. Segundo, isso torna o componente muito mais reutilizável e testável. Além disso, reduz re-renderizações desnecessárias. Portanto, o Avatar pode ser usado em qualquer contexto, não apenas com usuários. Consequentemente, o código fica mais limpo e performático. Assim, componentes mantêm baixo acoplamento.

    Dependency Inversion Principle no React

    O Dependency Inversion Principle (DIP) estabelece que componentes de alto nível não devem depender de componentes de baixo nível. Ambos devem depender de abstrações. Em React, isso significa usar injeção de dependências através de props, contexts ou custom hooks. Portanto, em vez de importar diretamente serviços ou APIs específicas, componentes devem receber essas dependências. Dessa forma, você facilita testes, reutilização e manutenção do código.

    Exemplo prático – API service injection:

    // ❌ Violando DIP - componente depende diretamente da API
    import { apiClient } from '@/services/api';
    
    function UserProfile({ userId }: { userId: number }) {
      const [user, setUser] = useState(null);
    
      useEffect(() => {
        // Dependência direta da implementação
        apiClient.get(`/users/${userId}`).then(setUser);
      }, [userId]);
    
      return <div>{user?.name}</div>;
    }
    
    // ✅ Seguindo DIP - injeção de dependência
    interface UserService {
      getUser: (id: number) => Promise<User>;
      updateUser: (id: number, data: Partial<User>) => Promise<User>;
    }
    
    // Context fornece a abstração
    const UserServiceContext = createContext<UserService | null>(null);
    
    function useUserService() {
      const service = useContext(UserServiceContext);
      if (!service) throw new Error('UserService not provided');
      return service;
    }
    
    // Componente depende da abstração
    function UserProfile({ userId }: { userId: number }) {
      const userService = useUserService();
      const [user, setUser] = useState<User | null>(null);
    
      useEffect(() => {
        userService.getUser(userId).then(setUser);
      }, [userId, userService]);
    
      return <div>{user?.name}</div>;
    }
    
    // Implementação concreta injetada
    const apiUserService: UserService = {
      getUser: (id) => apiClient.get(`/users/${id}`),
      updateUser: (id, data) => apiClient.put(`/users/${id}`, data)
    };
    
    function App() {
      return (
        <UserServiceContext.Provider value={apiUserService}>
          <UserProfile userId={1} />
        </UserServiceContext.Provider>
      );
    }

    Neste exemplo, o componente UserProfile não conhece a implementação específica da API. Primeiro, ele depende apenas da interface abstrata UserService. Segundo, a implementação real é injetada via Context. Além disso, isso facilita muito os testes. Portanto, você pode mockar facilmente o serviço em testes. Consequentemente, o componente fica desacoplado e testável. Assim, é fácil trocar implementações sem tocar no componente.

    Conclusão

    Aplicar os princípios SOLID no React não é apenas uma questão de seguir regras. Na verdade, é adotar uma mentalidade de código limpo e sustentável. Portanto, componentes bem projetados se tornam mais fáceis de entender, testar e manter. Além disso, equipes colaboram melhor quando o código segue padrões consistentes. Consequentemente, a qualidade do produto final aumenta significativamente.

    Cada princípio SOLID traz benefícios específicos para aplicações React. Primeiro, o Single Responsibility cria componentes focados e reutilizáveis. Segundo, Open/Closed permite extensão sem modificação, reduzindo bugs. Terceiro, Liskov Substitution garante comportamento consistente e previsível. Quarto, Interface Segregation evita dependências desnecessárias e melhora performance. Por fim, Dependency Inversion facilita testes e troca de implementações. Dessa forma, você constrói aplicações verdadeiramente escaláveis.

    Integrar SOLID com outras boas práticas potencializa ainda mais os resultados. Por exemplo, combinar com pensamento em componentes React e TypeScript cria código type-safe e bem estruturado. Além disso, aplicar princípios SOLID facilita testes automatizados e desenvolvimento isolado de componentes. Consequentemente, seu workflow de desenvolvimento se torna muito mais eficiente e confiável.

    Comece hoje aplicando SOLID nos seus projetos React. Primeiro, identifique componentes que violam algum princípio. Em seguida, refatore gradualmente seguindo os exemplos deste guia. Depois, estabeleça padrões de código na equipe. Finalmente, documente decisões arquiteturais. Portanto, a prática consistente de SOLID transformará a qualidade do seu código React. Além disso, você desenvolverá um instinto natural para identificar e evitar anti-patterns. Assim, suas aplicações se tornarão mais robustas, testáveis e preparadas para crescer.