wandres.dev
EL BODY DEL REDUCER · operadores y orden

Dividir un reducer que crece: encontrar el corte y ejecutarlo sin romper nada

Una feature no se parte cuando el fichero se hace largo, sino cuando su grafo interno se desconecta: grupos de acciones que solo tocan grupos disjuntos de campos del estado. Esta lección enseña a leer esa señal antes que las anécdotas —el `switch` interminable, los booleanos mutuamente excluyentes, el test que necesita media pantalla de preparación—, propone un criterio de corte apoyado en el acoplamiento entre acciones y estado, y describe la mecánica de cinco pasos que permite extraer el hijo con puentes temporales, componerlo según su cardinalidad y devolverle la palabra al padre mediante un caso `delegate`.

⏱ 20 min

Todo reducer que sobrevive lo suficiente engorda. Empieza con seis casos y termina con cuarenta; el State acumula banderas que solo tienen sentido en combinaciones concretas; el compilador tarda cada vez más en verificar el tipo del body; y el test más simple exige construir medio mundo. La tentación es tratarlo como un problema de higiene y trocear el fichero en extensiones, pero eso solo reparte el desorden. Partir bien una feature no es un ejercicio de formato: es descubrir una frontera que ya existía en el acoplamiento entre acciones y estado, y hacerla explícita en el sistema de tipos. Esta lección te da el criterio para encontrarla y el procedimiento para cruzarla sin dejar la app rota ni un solo commit.

🎯 Al terminar esta lección sabrás
  • Reconocer las señales objetivas de que una feature contiene dos features, más allá del tamaño del fichero.
  • Localizar el corte analizando qué grupos de acciones tocan qué grupos de campos del estado.
  • Ejecutar la extracción en pasos pequeños, con puentes temporales que mantienen vista y tests compilando.
  • Conectar padre e hijo con la cardinalidad correcta y comunicar hacia arriba con un caso delegate.

Las señales de que hay dos features dentro

El número de líneas es un mal indicador; un reducer de trescientas líneas con un dominio coherente es perfectamente sano, y uno de ochenta puede estar mezclando dos responsabilidades. Las señales que sí importan son estructurales.

Señal observable Umbral práctico Qué está delatando
Campos del State mutuamente excluyentes Dos o más booleanos que nunca son ciertos a la vez Un enum de destinos o una feature aparte sin modelar
Grupos de acciones con vocabulario propio Prefijos repetidos como filtro, edicion, exportar Un subdominio pidiendo su propio Action
Campos escritos por un solo grupo de acciones La mayoría de campos tocados por un único grupo El grafo de acoplamiento ya está partido
Preparación de los tests Más líneas de montaje que de aserción El estado necesario excede el que la prueba examina
Conflictos de fusión recurrentes Dos personas chocan siempre en el mismo switch Dos ritmos de cambio distintos en un solo tipo

La tercera fila es la decisiva y merece formalizarse. Dibuja un grafo bipartito: a un lado los casos de tu Action agrupados por vocabulario, al otro los campos de tu State, y una arista cada vez que un caso lee o escribe un campo. Si el grafo resultante se separa en dos componentes unidos por unas pocas aristas, esas aristas son la interfaz entre dos features y el resto es ruido. Si en cambio el grafo es denso —casi todos los casos tocan casi todos los campos— la feature es una sola por muy larga que sea, y partirla creará dos reducers que se pasan el estado a manotazos.

flowchart LR
A1[acciones de busqueda] --> C1[campo consulta]
A1 --> C2[campo resultados]
A1 --> C3[campo cargando]
A2[acciones de edicion] --> C4[campo borrador]
A2 --> C5[campo validaciones]
A2 -.unica arista cruzada.-> C2
C2 -.el corte pasa por aqui.-> A2

Elegir la cardinalidad antes de mover código

Localizado el corte, la siguiente decisión no es cómo escribir el hijo sino cuántas instancias suyas pueden coexistir, porque esa respuesta elige el operador sin margen de duda: si el hijo existe siempre, Scope; si existe o no, ifLet con @Presents; si existe muchas veces, forEach con IdentifiedArrayOf. Equivocarse aquí es lo que produce las extracciones que hay que deshacer, porque un hijo modelado como permanente cuando en realidad es presentado obliga después a inventar banderas de visibilidad en el padre.

1️⃣

Existe siempre

Un panel que forma parte permanente de la pantalla se incrusta con Scope. Su estado es un campo del padre y nunca es nulo.

Existe o no existe

Una hoja, una alerta o un detalle que se presenta usa @Presents y ifLet, que además cancela los efectos del hijo al desaparecer.

🔢

Existe muchas veces

Una fila repetida vive en un IdentifiedArrayOf y se compone con forEach, con acciones dirigidas por identificador estable.

⚠️
No partas por capas, parte por dominio

La división que nunca funciona es la que separa por tipo de trabajo: un reducer para la red, otro para el formateo, otro para la persistencia. Produce tres tipos que no pueden existir por separado, que se envían acciones constantemente y que ninguno se puede probar en soledad, es decir, el acoplamiento anterior con más ceremonia. La frontera correcta siempre es de dominio: un trozo del estado con sus eventos, capaz de arrancar solo en una preview.

La mecánica en cinco pasos

La clave para no romper nada es que cada paso compile y pase los tests existentes. El truco que lo hace posible es el puente temporal: propiedades calculadas en el padre que reenvían al estado ya anidado, de modo que la vista y las pruebas siguen viendo la forma antigua mientras el interior ya tiene la forma nueva.

// Paso 1: el estado del hijo se anida, pero el padre expone puentes.
@ObservableState
struct State: Equatable {
  var edicion = Edicion.State()
  var articulos: IdentifiedArrayOf<Articulo> = []

  // Puente temporal: se borra en el paso 5.
  var borrador: String {
    get { edicion.borrador }
    set { edicion.borrador = newValue }
  }
}
// Pasos 2 y 3: las acciones se agrupan y la logica se mueve literalmente.
@Reducer
struct Edicion {
  @ObservableState
  struct State: Equatable {
    var borrador = ""
    var validaciones: [String] = []
  }

  enum Action: Equatable {
    case borradorCambiado(String)
    case guardarPulsado
    case delegate(Delegate)

    @CasePathable
    enum Delegate: Equatable { case guardado(Articulo) }
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .borradorCambiado(texto):
        state.borrador = texto
        return .none
      case .guardarPulsado:
        return .send(.delegate(.guardado(Articulo(texto: state.borrador))))
      case .delegate:
        return .none
      }
    }
  }
}

El paso 4 es enchufar el hijo con el operador que eligió la cardinalidad, siempre por encima del Reduce propio del padre, como fijó la segunda lección de este nivel. El paso 5 es retirar los puentes y migrar la vista a store.scope(state:action:), un fichero cada vez; en cuanto el último puente desaparece, el compilador te señala todos los sitios que aún hablaban el lenguaje antiguo, y esa lista de errores es tu plan de trabajo restante.

Devolverle la palabra al padre

Un hijo extraído no puede alcanzar hacia arriba, y esa incapacidad es justo lo que lo hace reutilizable. Cuando necesita que el padre haga algo —cerrar una hoja, insertar el resultado en una lista, navegar— no lo hace: lo anuncia. La convención es un caso delegate en su Action con un enum anidado propio, que el hijo emite con .send y que el padre intercepta en su propio Reduce.

var body: some ReducerOf<Self> {
  Scope(state: \.edicion, action: \.edicion) { Edicion() }

  Reduce { state, action in
    switch action {
    case let .edicion(.delegate(.guardado(articulo))):
      state.articulos.append(articulo)
      return .none
    default:
      return .none
    }
  }
}

Tres precisiones. La primera: el hijo nunca debe reaccionar a sus propios casos delegate; son de salida y su rama devuelve .none. La segunda: como .send produce un Effect, la acción de delegación llega al padre en una pasada posterior, no en la misma; si esperabas verla en el mismo ciclo, el TestStore te lo dirá con un receive obligatorio. La tercera: el vocabulario del delegate debe hablar del dominio del hijo —guardado, cancelado— y jamás del padre —cerrarHoja, irADetalle—, porque en cuanto nombra al padre deja de poder enchufarse en otro sitio.

El corte no se inventa, se descubre; y el compilador lo custodia

Hay una diferencia de fondo entre partir una clase y partir un reducer, y explica por qué esta operación, que en otras arquitecturas se pospone durante años, aquí es rutinaria. Cuando divides una clase grande decides una frontera y confías en la disciplina para mantenerla: nada impide que mañana alguien pase una referencia por el constructor y las dos mitades vuelvan a enredarse, porque la frontera vive en la intención y en la revisión de código. Cuando divides un reducer, la frontera se materializa en el sistema de tipos: el hijo tiene su propio State y su propia Action, y no existe expresión válida en Swift con la que pueda leer un campo del padre. La separación deja de ser un acuerdo y pasa a ser una imposibilidad, y esa es una diferencia de categoría, no de grado. De ahí se sigue lo más interesante: el corte correcto no es una decisión de gusto sino un hecho observable sobre el código que ya escribiste. El grafo de qué acciones tocan qué campos existe antes de que lo dibujes; tú solo lo lees. Si el grafo está desconectado, la feature llevaba tiempo siendo dos y el tipo mentía; si está denso, cualquier corte será artificial y el precio se pagará en acciones de coordinación yendo y viniendo. Por eso conviene invertir el orden habitual del refactor: no partas cuando el fichero incomode, mide primero el acoplamiento y parte cuando el grafo lo autorice. Y por eso conviene también resistirse a la simetría —dos features no tienen por qué salir del mismo tamaño—: lo que buscas no es equilibrio, es una interfaz estrecha. Si el resultado son cuatro casos delegate y un Scope, la operación fue un éxito, mida lo que mida cada mitad.

⚔️ Parte una feature guiado por su grafo
  1. Elige tu reducer más largo y dibuja el grafo bipartito de acciones contra campos del State. Marca las aristas que cruzarían el corte que estás considerando.
  2. Si cruzan más de cuatro o cinco, busca otro corte o concluye que la feature es una sola. Anota tu decisión y el motivo.
  3. Anida el State del hijo dejando puentes calculados en el padre. Comprueba que la app compila y que todos los tests pasan sin tocar ni la vista ni las pruebas.
  4. Mueve las ramas del switch al hijo sin reescribirlas y engánchalo con el operador que corresponda a su cardinalidad, colocado por encima del Reduce del padre.
  5. Sustituye toda comunicación implícita por casos delegate con vocabulario del hijo, retira los puentes y migra la vista con store.scope. Termina escribiendo un TestStore del hijo que no mencione al padre en ninguna línea.