SOLID en frontend para dummies
SOLID en frontend para dummies
Voy a intentar explicar SOLID como me habría gustado que me lo explicaran a mí. Sin animales. Sin CEOs de Java. Sin “las abstracciones de las dependencias de las interfaces desacopladas”. Frontend. React. Cosas reales.
S — Single Responsibility Principle
La teoría dice: “una clase debe tener una única razón para cambiar”. Y tú te quedas igual.
Traducido al castellano: no metas toda la lógica en el mismo componente.
Cuando llevas un tiempo en esto, acabas viendo componentes como este:
function ProductPage() {
// fetch de productos
// lógica de filtros
// eventos de analytics
// estado del modal
// formulario de contacto
// validaciones
// localStorage
// navegación
}
Eso acaba teniendo 900 líneas, miedo a tocar nada, y ese pensamiento de “si funciona no lo toques” que es la antítesis de mantener código sano.
La idea es separar responsabilidades. Cada pieza hace una cosa:
// Cada hook gestiona su propia lógica
const { products, isLoading } = useProducts()
const { filters, setFilter } = useFilters()
const { trackEvent } = useAnalytics()
// Cada componente renderiza su propia parte
return (
<>
<ProductFilters filters={filters} onFilter={setFilter} />
<ProductList products={products} />
<ProductModal />
</>
)
Ahora useProducts solo sabe de productos. useFilters solo sabe de filtros. Si mañana cambia la API de productos, tocas un archivo, no buscas dónde está mezclado con los filtros y el modal.
No es religión arquitectónica. Es que dentro de tres meses, cuando vuelvas a este código, no quieras llorar.
O — Open/Closed Principle
La teoría dice: “abierto a extensión, cerrado a modificación”. Otra frase que parece escrita por un villano de Matrix.
Traducido: diseña las cosas para que añadir casos nuevos no implique reventar lo que ya funciona.
El ejemplo clásico, el de los colores de botón, se usa mucho para explicar esto pero tiene trampa. Si haces esto:
function getButtonColor(type: string) {
if (type === "primary") return "blue"
if (type === "danger") return "red"
if (type === "success") return "green"
}
Cada variante nueva implica editar la función, aumentar los if, y arriesgarte a romper algo que ya funcionaba. Acaba pareciendo una calculadora de Hacienda.
La versión con objeto es mejor, pero el punto importante no es “usa objetos”:
const buttonColors: Record<string, string> = {
primary: "blue",
danger: "red",
success: "green",
}
function getButtonColor(type: string) {
return buttonColors[type]
}
Ahora getButtonColor ya no cambia nunca. Para añadir warning simplemente extiendes la configuración:
buttonColors.warning = "yellow"
La función principal sigue intacta. Ese es el punto. No es el objeto en sí, es que la lógica central ya no necesita modificarse cada vez que el producto pide algo nuevo.
Donde esto se vuelve realmente poderoso es en sistemas más grandes. Imagina una pasarela de pagos:
// Mal: cada método nuevo requiere tocar esta función
function processPayment(method: string, amount: number) {
if (method === "stripe") {
// 20 líneas de lógica stripe
}
if (method === "paypal") {
// 20 líneas de lógica paypal
}
if (method === "bizum") {
// 20 líneas de lógica bizum
}
}
Añadir Klarna implica entrar en una función que ya funciona, tocarla, y rezar para no romper Stripe por el camino.
// Bien: cada procesador vive en su propio sitio
const paymentProcessors: Record<string, (amount: number) => void> = {
stripe: processStripe,
paypal: processPaypal,
bizum: processBizum,
}
function processPayment(method: string, amount: number) {
const processor = paymentProcessors[method]
if (!processor) throw new Error(`Método de pago desconocido: ${method}`)
return processor(amount)
}
Ahora añadir Klarna es esto:
paymentProcessors.klarna = processKlarna
processPayment no se toca. No hay riesgo de romper lo que ya funciona. Eso es OCP aplicado.
Lo mismo pasa con rutas, plugins, estrategias de ordenación, o cualquier sistema donde los casos crecen con el tiempo. La idea siempre es la misma: prepara el núcleo para que extenderlo sea añadir piezas, no reescribir el motor.
L — Liskov Substitution Principle
El principio con el nombre más pretencioso de la historia. Se llama así porque lo definió Barbara Liskov en los 80, no porque alguien quisiera complicarte la vida.
La definición original habla de herencia entre clases: si tienes una clase base Animal y una subclase Perro, deberías poder usar Perro en cualquier sitio donde se espere un Animal sin que nada explote. En backend con Java o C# esto se ve claro. En frontend moderno casi no usamos herencia, así que traducirlo necesita un poco más de trabajo.
La idea de fondo, aplicada a React, es esta: si un componente promete un contrato, cúmplelo en todos sus casos.
El ejemplo más típico es el del componente genérico que se comporta diferente según el tipo:
// Parece una buena idea hasta que lo usas
<Field type="text" onChange={(value) => setSomething(value)} />
<Field type="date" onChange={(value) => setSomething(value)} />
<Field type="file" onChange={(value) => setSomething(value)} />
La prop onChange promete recibir un value. Pero en la práctica, type="text" devuelve un string, type="date" puede devolver un Date, y type="file" devuelve un File. El contrato que prometiste en la firma del componente no se cumple igual en todos los casos.
TypeScript te ayuda a detectar esto, pero a veces el problema es más sutil: misma firma, comportamientos radicalmente distintos, props que solo aplican a uno de los tipos, condicionales internos creciendo sin control.
La solución es respetar el contrato, o dejar de fingir que hay uno solo:
// Cada componente tiene su contrato propio y claro
<TextField value={text} onChange={(value: string) => setText(value)} />
<DateField value={date} onChange={(value: Date) => setDate(value)} />
<FileField onChange={(file: File) => handleUpload(file)} />
Tres componentes separados, tres contratos distintos, ninguna sorpresa. Cuando usas TextField sabes exactamente qué entra y qué sale. No hay lógica condicional interna que rompa la promesa según el contexto.
LSP en frontend moderno no es tanto una regla de herencia como un recordatorio de que los contratos de tus componentes y hooks deben ser predecibles. Si algo firma (value: string) => void, que siempre sea un string. Si un hook devuelve { data, isLoading, error }, que siempre devuelva esa forma, no a veces un array y a veces un objeto.
I — Interface Segregation Principle
Traducido: no conviertas componentes en navajas suizas.
Cuando un componente acumula demasiada responsabilidad, suele notarse primero en las props. Algo así:
<UserCard
user={user}
onEdit={handleEdit}
onDelete={handleDelete}
onShare={handleShare}
onReport={handleReport}
isAdmin={isAdmin}
theme={theme}
analyticsContext={analyticsContext}
showActions={showActions}
compact={compact}
/>
Y luego en la mitad de la app lo usas así:
<UserCard user={user} />
El componente te obliga a conocer su interfaz completa aunque no la necesites. Empiezan los callbacks vacíos, los undefined por todos lados, los flags como showActions={false} para desactivar cosas que no deberían estar ahí, y las props opcionales que se acumulan edición tras edición.
La solución no es siempre “crea un componente por caso de uso”. A veces es separar las responsabilidades en capas:
// La parte base, sin lógica de acciones
<UserCard user={user} />
// Una versión extendida para cuando necesitas edición
<EditableUserCard user={user} onEdit={handleEdit} onDelete={handleDelete} />
// O componer directamente si tienes más control
<UserCard user={user}>
<UserActions onEdit={handleEdit} onDelete={handleDelete} />
</UserCard>
La idea es que cada pieza solo pida lo que necesita. Un componente que muestra información de usuario no debería necesitar saber nada de permisos de admin, contexto de analytics o handlers de acciones que no usa. Si lo necesita, señal de que está haciendo demasiado.
D — Dependency Inversion Principle
El nombre más exagerado para algo bastante lógico.
Traducido: no importes implementaciones concretas por toda la app. Depende de abstracciones.
El ejemplo más directo en frontend:
// Mal: axios vive en 94 archivos
import axios from "axios"
// En un componente cualquiera
const response = await axios.get("/users")
El día que quieres cambiar axios por fetch nativo, por una librería diferente, o por un cliente con interceptores personalizados, tienes que buscar cada import en cada archivo. Y peor: tienes que asegurarte de que el comportamiento es idéntico en todos ellos.
La alternativa es que axios viva en un solo sitio y el resto de la app no sepa que existe:
// src/lib/apiClient.ts
import axios from "axios"
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
})
// Interceptores, manejo de errores, refresh tokens... todo aquí
apiClient.interceptors.response.use(
(response) => response.data,
(error) => handleApiError(error)
)
export default apiClient
// En cualquier servicio de la app
import apiClient from "@/lib/apiClient"
export const getUsers = () => apiClient.get("/users")
export const createUser = (data: UserDTO) => apiClient.post("/users", data)
Ahora si mañana cambias axios por otra cosa, tocas apiClient.ts. El resto de la app no se entera. Los tests pueden mockear apiClient sin saber nada de axios.
Lo mismo aplica a cualquier dependencia externa que pueda cambiar o que quieras aislar: librerías de analytics, clientes de WebSocket, adaptadores de almacenamiento local. La app depende de tu abstracción, no de la librería directamente.
Conclusión
SOLID no son mandamientos divinos. Son ideas que llevan décadas ayudando a que el código no se convierta en un problema cada vez más difícil de tocar.
En frontend, donde no hay clases ni herencia por defecto, muchos de estos principios se manifiestan de formas distintas a las del libro. Pero la intención es la misma: reduce el acoplamiento, separa responsabilidades, prepara el código para que cambiar una cosa no implique romper cinco.
La mayoría de veces, si separas la lógica en hooks con una sola responsabilidad, no haces componentes con veinte props, no importas librerías externas por toda la app, y diseñas para que añadir casos nuevos sea extender en lugar de modificar, ya estás aplicando medio SOLID sin haberlo estudiado.
El resto es nomenclatura.