Aplicando os princípios de SOLID no React.

Robert C. Martins criou cinco princípios de design de softwares com o objetivo de tornar um código, mais legível, sustentável e testável. Esses princípios são conhecidos como SOLID.

Inicialmente, esses princípios foram pensados para a programação orientada a objetos, mas hoje eu quero trazer uma adaptação para o ecossistema do React, pensando em componentes e hooks.

Antes de fazermos o todo list mais robusto de todos os tempos, lembre-se de que são sugestões e princípios que precisam ser considerados com cuidado ao serem aplicados no seu projeto.

Não precisa sair aplicando todos os princípios em todos os componentes e refatorando tudo. Ok?

Single Responsibility Principle (SRP)

Na definição original desse princípio, é dita que uma classe deve ter apenas uma razão para mudar. No contexto do React, a regra é que cada componente, hook ou função deve fazer apenas uma única coisa.

Para verificar se seu componente ou hook viola este princípio, se pergunte:

  • Este componente deve mostrar a UI ou lidar com os dados?
  • Qual tipo único de dados este hook deve manipular?
  • Pertence a camada de armazenamento de dados ou UI?

Se o hook ou o componente não tiver uma resposta única para essas perguntas, ele está violando o princípio. Procure separar a lógica da apresentação visual. Coloque a lógica em hooks com responsabilidades únicas e separe componentes de forma isolada.

Componentes e hooks que seguem este princípio são mais propensos a serem reutilizáveis, mais fáceis de serem testados e de manter. São componentes e elementos desacoplados que evitam conflitos.

A seguir temos um exemplo de código ruim que viola o SRP. Neste exemplo, o componente TodoList faz quatro coisas ao mesmo tempo: gerencia o estado da lista, faz a chamada para a API, contém a lógica de filtragem e define toda a interface visual.

// TodoListBAD.tsx
import React, { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
 
export function TodoList() {
  const [todos, setTodos] = useState<any[]>([]);
  const [filter, setFilter] = useState('all');
 
  // Responsabilidade 1: Buscar dados da API
  useEffect(() => {
    axios.get('https://api.exemplo.com/todos').then(res => setTodos(res.data));
  }, []);
 
  // Responsabilidade 2: Lógica de filtragem
  const filteredTodos = useMemo(() => {
    return todos.filter(todo => 
      filter === 'all' ? true : todo.status === filter
    );
  }, [todos, filter]);
 
  // Responsabilidade 3 e 4: Renderizar UI de filtro e Lista de itens
  return (
    <div>
      <h1>Minhas Tarefas</h1>
      <select onChange={(e) => setFilter(e.target.value)}>
        <option value="all">Todas</option>
        <option value="completed">Concluídas</option>
      </select>
 
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id} style={{ padding: '10px', borderBottom: '1px solid #ccc' }}>
            <input type="checkbox" checked={todo.completed} readOnly />
            <span>{todo.title}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

Agora vamos refatorar esse código, aplicando o SRP. Para aplicar o SRP, devemos quebrar o componente grande em partes menores com responsabilidades únicas.

Primeiro, lidamos com a lógica. Extraímos a busca de dados e o gerenciamento de estado para um hook personalizado.

// useTodos.ts
export function useTodos() {
  const [todos, setTodos] = useState([]);
  useEffect(() => {
    axios.get('https://api.exemplo.com/todos').then(res => setTodos(res.data));
  }, []);
  return todos;
}

Segundo, criamos componentes para lidar apenas com a exibição, lidando com a parte visual.

// TodoItem.tsx (Apenas renderiza um único item)
export function TodoItem({ todo }: { todo: any }) {
  return (
    <li className="todo-item">
      <input type="checkbox" checked={todo.completed} readOnly />
      <span>{todo.title}</span>
    </li>
  );
}
 
// TodoFilter.tsx (Apenas lida com a mudança de filtro)
export function TodoFilter({ onFilterChange }: { onFilterChange: (f: string) => void }) {
  return (
    <select onChange={(e) => onFilterChange(e.target.value)}>
      <option value="all">Todas</option>
      <option value="completed">Concluídas</option>
    </select>
  );
}

Por fim, criamos o componente principal, que compõe as peças, tornando-o muito mais legível e fácil de manter.

// TodoListGOOD.tsx
export function TodoList() {
  const todos = useTodos(); // Hook especializado
  const [filter, setFilter] = useState('all');
 
  const filteredTodos = todos.filter(t => filter === 'all' || t.status === filter);
 
  return (
    <div>
      <h1>Minhas Tarefas</h1>
      <TodoFilter onFilterChange={setFilter} />
      <ul>
        {filteredTodos.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </div>
  );
}

Com essa refatoração, temos um componente TodoItem e um hook useTodos, que podem ser usados em outras partes da aplicação.

O hook useTodos ficou simples de ser testado, tendo sua lógica separada da renderização do componente.

Ambos, componente e hook, podem ser atualizados e mantidos sem problemas de conflito, mudando a estilização ou a API utilizada.

Open/Closed Principle (OCP)

A ideia desse princípio é que componentes, hooks e funções devem estar abertos para extensão, mas fechados para modificações.

No contexto do React, isso significa que você deve ser capaz de adicionar novas funcionalidades ou variações visuais a um componente sem modificar o código original dele.

Na prática, pensando em componentes, esse princípio pode ser aplicado utilizando props para manipular os componentes, fazer uma personalização ou extensão do mesmo, ou a criação de componentes seguindo o padrão de compound components.

No caso dos hooks, podemos usar a criação de hooks personalizados que chamam outros hooks sem precisar manipular o código original do hook a ser utilizado.

Ainda utilizando o nosso Todo List, a seguir temos um exemplo ruim, que viola o OCP. Aqui, o componente TodoItem tenta lidar com todos os tipos possíveis de tarefas internamente. Se amanhã surgir um novo tipo de tarefa, você precisará modificar este arquivo, correndo o risco de quebrar os tipos que já funcionam.

// TodoItemBAD.tsx
import React from 'react';
 
type TodoType = 'simple' | 'deadline' | 'link';
 
interface Todo {
  id: number;
  type: TodoType;
  title: string;
  completed: boolean;
  meta?: any; // Dados extras (data, url, etc)
}
 
export function TodoItem({ todo, onToggle }: { todo: Todo; onToggle: () => void }) {
  return (
    <li className="todo-item">
      <input type="checkbox" checked={todo.completed} onChange={onToggle} />
      
      {/* VIOLAÇÃO OCP: Lógica condicional rígida dentro do componente */}
      <div className="content">
        <span>{todo.title}</span>
        
        {todo.type === 'deadline' && (
          <span className="date"> 📅 {todo.meta.dueDate}</span>
        )}
        
        {todo.type === 'link' && (
          <a href={todo.meta.url} className="link"> 🔗 Abrir Link</a>
        )}
        
        {/* Se precisarmos de um tipo 'shopping', teremos que editar este arquivo e adicionar mais um IF */}
      </div>
    </li>
  );
}

Agora vamos refatorar este código aplicando o princípio de OCP. Primeiro, transformamos o TodoItem em um componente base genérico que aceita conteúdo dinâmico através de um children ou props. Assim, ele fica fechado para modificações, mas aberto para extensão/personalização.

// TodoItemBase.tsx
import React, { ReactNode } from 'react';
 
interface TodoItemBaseProps {
  completed: boolean;
  onToggle: () => void;
  children: ReactNode; // Ponto de extensão
}
 
export function TodoItemBase({ completed, onToggle, children }: TodoItemBaseProps) {
  return (
    <li className="todo-item">
      <input type="checkbox" checked={completed} onChange={onToggle} />
      <div className="content">
        {children} 
      </div>
    </li>
  );
}

Agora, criamos componentes que usam a base, e se um novo tipo de tarefa surgir, criamos um novo arquivo ao invés de alterar o antigo.

// TodoItemVariants.tsx
 
// Tarefa Simples
export function SimpleTodoItem({ todo, onToggle }: { todo: any; onToggle: () => void }) {
  return (
    <TodoItemBase completed={todo.completed} onToggle={onToggle}>
      <span>{todo.title}</span>
    </TodoItemBase>
  );
}
 
// Tarefa com Prazo (Extensão 1)
export function DeadlineTodoItem({ todo, onToggle }: { todo: any; onToggle: () => void }) {
  return (
    <TodoItemBase completed={todo.completed} onToggle={onToggle}>
      <span>{todo.title}</span>
      <span className="date" style={{ color: 'red', marginLeft: '10px' }}>
        📅 Vence em: {todo.meta.dueDate}
      </span>
    </TodoItemBase>
  );
}
 
// Tarefa com Link (Extensão 2)
export function LinkTodoItem({ todo, onToggle }: { todo: any; onToggle: () => void }) {
  return (
    <TodoItemBase completed={todo.completed} onToggle={onToggle}>
      <span>{todo.title}</span>
      <a href={todo.meta.url} target="_blank" style={{ marginLeft: '10px' }}>
        🔗 Acessar
      </a>
    </TodoItemBase>
  );
}

Por fim, na lista principal escolhemos qual componente renderizar. Mesmo que tenha um lógica utilizando o switch, não estamos violando o SRP, pois essa é uma camada de composição, e não de apresentação.

// TodoList.tsx
export function TodoList() {
  const todos = useTodos(); // Hook do exemplo anterior
 
  const renderTodoItem = (todo: any) => {
    switch (todo.type) {
      case 'deadline':
        return <DeadlineTodoItem key={todo.id} todo={todo} onToggle={() => {}} />;
      case 'link':
        return <LinkTodoItem key={todo.id} todo={todo} onToggle={() => {}} />;
      default:
        return <SimpleTodoItem key={todo.id} todo={todo} onToggle={() => {}} />;
    }
  };
 
  return (
    <ul>
      {todos.map(renderTodoItem)}
    </ul>
  );
}

Com esta refatoração, nós temos mais flexibilidade, e o design de cada tipo de tarefa pode ser diferente. O componente TodoItemBase fica limpo, focando apenas na estrutura.

Temos mais segurança, pois podemos criar um componente novo como ShoppingTodoItem sem medo de quebrar a lógica de exibição no LinkTodoItem.

Liskov Substitution Principle (LSP)

Esse princípio diz que objetos subtipos devem ser substituíveis por objetos supertipos.

Isso pode ser aplicado no React utilizando o TypeScript. Ao utilizar interfaces (interface) e extensão de tipos (extends), e forçar o contrato de que o subtipo deve possuir todas as propriedades do supertipo

A ideia é ter a capacidade de trocar componentes ou hooks por outros do mesmo tipo sem quebrar a aplicação. Se um componente/hook aceita certas props (entradas), todos os componentes/hooks que o estendem ou substituem devem aceitar as mesmas props e retornar valores compatíveis.

A seguir temos um exemplo de componente que viola o princípio LSP, pois ele renomeia um evento padrão e limita a quantidade de props aceitas. Se uma prop como "placeholder" for passada, o componente quebrará ou ignorará, pois ele não espera receber essa prop.

// TaskInputBAD.tsx
import React from 'react';
 
// Define uma interface restrita e proprietária
interface TaskInputProps {
  val: string;
  onTextChange: (text: string) => void; // Violação: Renomeando comportamento padrão
}
 
export function TaskInputBad({ val, onTextChange }: TaskInputProps) {
  return (
    <input 
      type="text"
      className="border p-2 rounded"
      value={val}
      // Violação: A lógica interna força uma assinatura diferente da nativa
      onChange={(e) => onTextChange(e.target.value)}
    />
  );
}
 
// Uso na TodoList:
<TaskInputBad val={text} onTextChange={setText} /> 
// Se tentarmos adicionar placeholder="Nova tarefa", vai dar erro de TypeScript/Runtime.

Agora vamos refatorar esse código aplicando o princípio de LSP. Primeiro vamos estender a interface nativa InputHTMLAttributes. Depois usamos o spred operator para garantir que qualquer prop padrão (ex: placeholder) funcione corretamente.

// TaskInputGOOD.tsx
import React, { InputHTMLAttributes } from 'react';
 
// Estendemos a interface nativa do input.
// O componente agora é um "subtipo" válido de um HTMLInputElement
interface TaskInputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string; // Podemos estender com novas props (Open-Closed Principle)
}
 
export function TaskInputGood({ label, className, ...props }: TaskInputProps) {
  return (
    <div className="flex flex-col gap-1">
      {label && <label className="text-sm font-bold text-gray-700">{label}</label>}
      <input
        className={`border p-2 rounded focus:outline-blue-500 ${className || ''}`}
        {...props} // Repassa todas as props nativas (Princípio de Liskov)
      />
    </div>
  );
}

Agora que nosso componente segue o padrão LSP, podemos usar eventos de teclado (onKeyDown) e atributos de acessibilidade (aria-label) que não foram explicitamente declarados no nosso componente, mas funcionam porque respeitamos o contrato base que ele utiliza.

// TodoList.tsx
import React, { useState } from 'react';
import { TaskInputGood } from './TaskInputGOOD';
 
export function TodoList() {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<string[]>([]);
 
  const handleAdd = () => {
    if (text) {
      setTodos([...todos, text]);
      setText('');
    }
  };
 
  return (
    <div>
      <h1>Minhas Tarefas</h1>
      
      {/* Aqui aplicamos o LSP: Estamos usando props nativas (placeholder, onKeyDown)
         que o TaskInputGood aceita naturalmente por estender o input padrão.
      */}
      <TaskInputGood
        label="Nova Tarefa"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Digite e pressione Enter..."
        autoFocus
        onKeyDown={(e) => {
          if (e.key === 'Enter') handleAdd();
        }}
      />
 
      <ul style={{ marginTop: '20px' }}>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

Com essa refatoração, ganhamos previsibilidade. Devs do time sabem utilizar o nosso componente e não precisam aprender sobre eventos novos. Também ganhamos flexibilidade, pois nosso componente tem suporte nativo a propriedades que podem ser valiosas para nós no futuro e já funciona automaticamente.

Interface Segregation Principle (ISP)

A ideia original desse princípio é que clientes não devem ser forçados a depender de interfaces que não utilizam. No caso do React, podemos pensar que componentes não devem receber props que não utilizam.

É muito comum passar um objeto inteiro para dentro do componente quando ele precisa apenas de um único elemento, às vezes definindo as propriedades como opcionais, mas o ideal é que seja passado apenas aquilo que é essencial.

No exemplo a seguir, temos um componente que viola o ISP, nele passamos um objeto todo inteiro para os componentes filhos, mesmo com eles precisando apenas do title, criamo um acoplamento desnecessário.

// Definição do objeto Todo completo
interface Todo {
  id: string;
  title: string;
  description: string;
  isCompleted: boolean;
  createdAt: Date;
  userId: string; // Dados extras que componentes visuais não precisam saber
}
 
// O componente 'TodoTitle' recebe o objeto inteiro, 
// mas só usa o 'title'. Ele "conhece" dados que não deveria (userId, id, etc).
const TodoTitle = ({ todo }: { todo: Todo }) => {
  return <h1>{todo.title}</h1>;
};
 
// O componente 'TodoDate' recebe tudo, mas só usa 'createdAt'.
const TodoDate = ({ todo }: { todo: Todo }) => {
  return <span>Criado em: {todo.createdAt.toLocaleDateString()}</span>;
};
 
export const TodoListBad = () => {
  const myTodo: Todo = {
    id: "1",
    title: "Estudar SOLID",
    description: "Ler a documentação",
    isCompleted: false,
    createdAt: new Date(),
    userId: "user_123"
  };
 
  return (
    <div className="card">
      {/* Estamos forçando dependências desnecessárias aqui */}
      <TodoTitle todo={myTodo} />
      <TodoDate todo={myTodo} />
    </div>
  );
};

Refatorando esse código, devemos quebrar as interfaces passando os dados necessários para o componente funcionar.

// Definimos uma interface específica apenas para o que o componente precisa.
// Ou, neste caso simples, passamos a primitiva string diretamente.
interface TitleProps {
  title: string;
}
 
const TodoTitle = ({ title }: TitleProps) => {
  return <h1>{title}</h1>;
};
 
// Este componente só precisa de uma data, nada mais.
// Agora ele pode ser usado para exibir datas de Tarefas, Projetos ou Comentários.
interface DateDisplayProps {
  date: Date;
}
 
const TodoDate = ({ date }: DateDisplayProps) => {
  return <span>Criado em: {date.toLocaleDateString()}</span>;
};
 
// Componente Principal
export const TodoListGood = () => {
  const myTodo = {
    id: "1",
    title: "Estudar SOLID",
    description: "Ler a documentação",
    isCompleted: false,
    createdAt: new Date(),
    userId: "user_123"
  };
 
  return (
    <div className="card">
      {/* Aplicando ISP: Passamos apenas o necessário */}
      <TodoTitle title={myTodo.title} />
      <TodoDate date={myTodo.createdAt} />
    </div>
  );
};

Com essa refatoração, o componente TodoDate se torna genérico, ele aceita qualquer Date e não apenas datas vindas do objeto Todo.

A estrutura do objeto Todo pode mudar, como por exemplo, o userId que o componente TodoTitle não quebrará, pois ele não depende dessa propriedade.

Olhando o <TodoTitle title={...} /> percebemos quais dados o componente consome.

Dependency Inversion Principle (DIP)

Nesse princípio, a ideia é que componentes, funções ou módulos não devem depender de implementações concretas, mas sim de abstrações comuns.

Em geral, devemos focar em desacoplamento entre interfaces visuais e lógica, utilizando props, hooks e serviços. Evitando componente que contém lógica hardcoded dentro deles.

Nesse último exemplo, temos um componente CreateTodo, responsável por salvar a tarefa. Ele importa um serviço diretamente, criando um acomplamento, onde para mudarmos o serviço seria preciso reescrever o componente.

// O componente depende de uma implementação concreta (axios + URL específica)
import { useState } from "react";
import axios from "axios"; // Dependência direta de baixo nível
 
export const CreateTodoBad = () => {
  const [title, setTitle] = useState("");
 
  const handleSave = async () => {
    try {
      // O componente "sabe demais". Se a API mudar, o componente quebra.
      await axios.post("https://api.meuapp.com/todos", { title });
      alert("Tarefa salva!");
      setTitle("");
    } catch (error) {
      console.error(error);
    }
  };
 
  return (
    <div>
      <input 
        value={title} 
        onChange={(e) => setTitle(e.target.value)} 
        placeholder="Nova tarefa..." 
      />
      <button onClick={handleSave}>Salvar</button>
    </div>
  );
};

Aplicando o DIP ao refatorar o componente, ele não define como salvar, define apenas uma abstração (interface de prop) que diz que uma tarefa precisa ser salva. A implementação real é injetada de fora.

Para isso, primeiros definimos o contrato que o serviço precisa seguir, com isso nosso componente depende da interface ITodoService, ao invés do axios.

import { useState } from "react";
 
// O componente recebe a abstração via props (Injeção de Dependência)
interface CreateTodoProps {
  todoService: ITodoService; 
}
 
export const CreateTodoGood = ({ todoService }: CreateTodoProps) => {
  const [title, setTitle] = useState("");
 
  const handleSave = async () => {
    try {
      // O componente apenas chama o método da interface.
      // Ele não sabe se vai para API, Firebase ou LocalStorage.
      await todoService.saveTodo(title);
      alert("Tarefa salva!");
      setTitle("");
    } catch (error) {
      console.error(error);
    }
  };
 
  return (
    <div>
      <input 
        value={title} 
        onChange={(e) => setTitle(e.target.value)} 
        placeholder="Nova tarefa..." 
      />
      <button onClick={handleSave}>Salvar</button>
    </div>
  );
};

Por fim, criamos a implementação real e injetamos no componente.

import axios from "axios";
 
// Implementação Concreta 1: Salva na API
const apiTodoService: ITodoService = {
  saveTodo: async (title: string) => {
    await axios.post("https://api.meuapp.com/todos", { title });
  },
};
 
// Implementação Concreta 2: Salva no LocalStorage (para testes ou modo offline)
const localTodoService: ITodoService = {
  saveTodo: async (title: string) => {
    const current = JSON.parse(localStorage.getItem("todos") || "[]");
    localStorage.setItem("todos", JSON.stringify([...current, { title }]));
  },
};
 
// Uso na Aplicação
export const App = () => {
  // Podemos trocar "apiTodoService" por "localTodoService" sem tocar no componente visual!
  return (
    <div>
      <h1>Minha Lista</h1>
      <CreateTodoGood todoService={apiTodoService} />
    </div>
  );
};

Com essa refatoração, nosso componente pode facilmente injetar um mock que não faz chamadas reais para ser testado visualmente.

Caso o backend mude, um novo serviço pode ser criado e injetado, mantendo a interface visual intacta. Pois o componente visual não tem a resposabilidade de salvar os dados.

˜ A respeito dos princípios de SOLID, seria apenas isso. Em breve, falaremos mais sobre Clean Code, pra aumentar ainda mais o seu arsenal de conhecimento e criar códigos incríveis.