withDependencies: sobrescribir para un ámbito
Declarar dependencias solo sirve si puedes sustituirlas, y el punto de sustitución es withDependencies: una función que instala un contenedor modificado durante la ejecución de una clausura y lo retira al salir. Esta lección examina su semántica de alcance dinámico, el detalle crítico de que la sobrescritura debe envolver la construcción del reducer y no solo el envío de acciones, y sus tres usos canónicos: la cláusula del TestStore, las previews de SwiftUI con datos de muestra y la sustitución acotada a una rama concreta del árbol de features. También cubre prepareDependencies en el punto de entrada, la herencia de contenedor entre objetos y por qué la propagación llega a las tareas asíncronas hijas.
Declarar el mundo como argumento no vale de nada si nunca eliges otro argumento. La contrapartida de @Dependency es withDependencies, y su semántica es la de un ámbito: instala un contenedor modificado, ejecuta lo que le des dentro y lo retira al salir, dejando el resto de la app exactamente como estaba. Es alcance dinámico —lo que un lenguaje como Lisp llamaría una variable especial— y no herencia ni configuración global: la sustitución vale dentro de esas llaves y en todo lo que nazca dentro de ellas, incluidas las tareas asíncronas hijas, y en ningún sitio más. Esa combinación de potencia y contención es lo que permite que un test instale un cliente falso sin que el test de al lado se entere, que una preview dibuje con datos de muestra sin tocar producción y que una rama del árbol de features corra con una base de datos distinta a la de sus hermanas.
- Usar
withDependenciesentendiendo su semántica de alcance dinámico y su ámbito de propagación. - Situar correctamente la sobrescritura respecto a la construcción del reducer, y diagnosticar por qué falla si va después.
- Aplicar los tres usos canónicos: la cláusula del
TestStore, las previews con datos de muestra y una rama concreta del árbol. - Conocer
prepareDependenciesen el punto de entrada y la herencia de contenedor entre objetos de larga vida.
La forma del ámbito
La función toma dos clausuras: una que recibe el contenedor y lo modifica, y otra que ejecuta el trabajo bajo ese contenedor modificado. Todo lo que ocurra dentro de la segunda —incluidas las llamadas anidadas y las tareas que se lancen desde ahí— verá los valores sustituidos.
withDependencies {
$0.uuid = .incrementing
$0.date = .constant(Date(timeIntervalSince1970: 0))
$0.continuousClock = ImmediateClock()
} operation: {
// Aqui dentro el mundo es el que acabas de describir
let feature = Feature()
// ...
}
// Al salir, el contenedor vuelve a ser el de antes
Tres propiedades definen su comportamiento y conviene enunciarlas con precisión. Es aditiva: dentro del ámbito, las dependencias que no tocas conservan el valor que tenían fuera, así que sustituyes solo lo que te interesa. Es anidable: un withDependencies dentro de otro hereda el contenedor del externo y le aplica sus propios cambios, de modo que los ámbitos se apilan como capas. Es reversible: al terminar la clausura de operación, el contenedor anterior se restaura íntegro, incluso si salió por una excepción.
La reversibilidad merece un subrayado, porque es lo que hace seguros los tests en paralelo: dos casos que sobrescriben la misma clave con valores distintos no pueden interferir, ya que cada uno vive en su propia capa y ninguna sobrevive a su clausura.
La propagación a tareas asíncronas es el detalle que hace que todo esto sea utilizable en la práctica. Un efecto lanzado desde dentro del ámbito hereda el contenedor aunque se ejecute más tarde y en otro hilo, porque la biblioteca lo transporta por el contexto de la tarea. Sin esa propagación tendrías que pasar las dependencias a mano a cada efecto, que es justamente el problema del que huías.
El detalle que rompe la mitad de los intentos
Recuerda de la lección anterior que una propiedad marcada con @Dependency captura su valor al construir el reducer. De ahí se sigue una regla que causa un buen número de tests desconcertantes: la sobrescritura tiene que envolver la construcción, no solo el uso.
// Incorrecto: el reducer ya capturo el uuid real antes de entrar al ambito
let feature = Feature()
withDependencies {
$0.uuid = .incrementing
} operation: {
// demasiado tarde para lo que Feature capturo
}
// Correcto: el reducer nace dentro del ambito
withDependencies {
$0.uuid = .incrementing
} operation: {
let feature = Feature()
// ...
}
Por eso el TestStore y el Store exponen una cláusula de sobrescritura en su propio inicializador: garantiza que las sustituciones estén vigentes en el instante exacto en que el reducer y todos sus hijos se construyen. Cuando un test se comporta como si tu sustitución no existiera, esta es casi siempre la causa, y el síntoma delator es que la dependencia real se usó pese a estar sobrescrita en apariencia.
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.clienteAPI.cargarArticulos = { [.demo] }
$0.uuid = .incrementing
$0.continuousClock = ImmediateClock()
}
Esa cláusula es sintácticamente un withDependencies disfrazado, y todo lo dicho sobre alcance, anidamiento y propagación se le aplica igual. Nada nuevo que aprender: el mismo mecanismo, colocado donde tiene que estar.
Los tres usos canónicos
Tests
La cláusula del TestStore instala clientes falsos, relojes de test y generadores deterministas. Cada test declara qué trozos del mundo toca, y esa declaración es documentación de su alcance real.
Previews
En el canvas de SwiftUI se sustituye la red por datos de muestra, de modo que la preview dibuja al instante y sin backend. Puedes tener varias previews del mismo componente con mundos distintos: lista llena, lista vacía, error.
Una rama del árbol
Al componer un hijo puedes envolver su construcción para que corra con una dependencia distinta a la de sus hermanos: una base de datos en memoria en el modo demostración, una API apuntando a preproducción en la pantalla de depuración.
El tercer uso es el menos conocido y el más revelador de la naturaleza del sistema. Nada obliga a que toda la app comparta el mismo contenedor: puedes acotar una sustitución a un subárbol concreto de features. El caso de la aplicación con modo demostración es el ejemplo perfecto: la misma jerarquía de reducers, sin una sola bifurcación en la lógica, corriendo contra una persistencia en memoria porque alguien envolvió su construcción en un ámbito distinto. La feature no sabe que está en modo demostración, y esa ignorancia es exactamente lo que la mantiene simple.
// En una preview: mundo de muestra, sin red
#Preview {
ListaView(
store: Store(initialState: Lista.State()) {
Lista()
} withDependencies: {
$0.clienteAPI.cargarArticulos = { Articulo.muestra }
}
)
}
Punto de entrada, herencia y objetos de larga vida
Hay sustituciones que no son de un ámbito sino de toda la ejecución: apuntar a un servidor de preproducción cuando se compila en configuración de depuración, o desactivar la analítica en las compilaciones internas. Para eso existe prepareDependencies, que se invoca una sola vez en el punto de entrada de la app, antes de que se construya nada, y fija valores para el resto del proceso.
@main
struct MiApp: App {
init() {
prepareDependencies {
$0.clienteAPI = .preproduccion
}
}
// ...
}
Es deliberadamente restrictivo: solo puede llamarse una vez y solo al principio, precisamente para que no degenere en una configuración global mutable desde cualquier parte. La regla mental es limpia: prepareDependencies para lo que vale para toda la ejecución del proceso, withDependencies para lo que vale para un ámbito.
Queda un caso final, el de los objetos de larga vida que se crean fuera de cualquier ámbito. Si un modelo instancia otro más tarde, el nuevo no hereda las sustituciones porque el ámbito original ya se cerró. La solución es withDependencies(from:), que copia el contenedor que un objeto capturó al nacer y lo instala para construir el siguiente, encadenando el linaje de dependencias a lo largo de la vida de la app en vez de perderlo en la primera frontera asíncrona.
flowchart TD G[Contenedor global con valores por defecto] --> W[withDependencies instala capa nueva] W --> B[Construccion del reducer dentro del ambito] B --> C[Propiedades Dependency capturan los valores sustituidos] B --> E[Efectos hijos heredan por contexto de tarea] W --> R[Al salir se restaura el contenedor anterior] style W fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b
Detente en la elección técnica de fondo, porque es la que decide todo lo demás. El alcance léxico —lo que usa cualquier lenguaje moderno para las variables normales— responde a la pregunta de qué ve una función mirando dónde está escrita. El alcance dinámico responde mirando desde dónde se la llamó. Casi siempre el léxico es superior, porque es predecible con solo leer el archivo, y por eso los lenguajes abandonaron el dinámico hace décadas. Pero para las dependencias la relación se invierte de manera exacta, y la razón es que la pregunta cambia: no quieres saber qué reloj usa esta función según dónde la escribiste, sino según en qué mundo la estás ejecutando —producción, test, preview, demostración—. Esa es literalmente una pregunta sobre el contexto de llamada, y el alcance dinámico es la respuesta natural a las preguntas sobre el contexto de llamada. La alternativa léxica existe y ya la evaluamos: pasar todo por inicializador. Es predecible, sí, pero paga un precio brutal, porque codifica el mundo en las firmas de todo el árbol y hace que añadir una dependencia a una hoja obligue a reescribir la rama entera. El alcance dinámico rompe ese acoplamiento en vertical: el proveedor está en la raíz, el consumidor en la hoja, y ninguno de los intermediarios necesita saber que la conversación existe. Y aquí está la parte que rescata la predecibilidad perdida: withDependencies es dinámico pero acotado y reversible, dos adjetivos que los ámbitos dinámicos históricos no tenían. Una sustitución vale dentro de la clausura y muere al salir, así que ningún test puede envenenar al siguiente, ninguna preview puede filtrarse a producción y ninguna rama del árbol puede contaminar a sus hermanas. Es la potencia del alcance dinámico con la contención del léxico, y esa síntesis —no el property wrapper, que solo es sintaxis— es la verdadera aportación de diseño del sistema.
- Toma una feature que consulte la red y escribe tres previews del mismo componente: una con datos de muestra, otra con lista vacía y otra donde el cliente lance un error. Comprueba que ninguna toca el backend.
- Escribe un test cuya sobrescritura esté colocada después de construir el reducer. Observa el fallo, muévela a la cláusula del inicializador y explica exactamente qué cambió en el momento de captura.
- Anida dos ámbitos: uno externo que fije el reloj y otro interno que fije además el generador de identificadores. Verifica que dentro del interno rigen ambos y que al salir se restaura el externo intacto.
- Añade a tu app un modo demostración envolviendo la construcción de una rama concreta con una persistencia en memoria, sin escribir una sola condición dentro de los reducers de esa rama.
- Usa
prepareDependenciesen el punto de entrada para apuntar a preproducción solo en compilaciones de depuración, y razona por qué eso no debería hacerse conwithDependenciesen su lugar.