onChange() en SwiftUI: reacciona a cambios con precisión
Arturo Rivas Arias
🔔 El modificador onChange() es la herramienta de SwiftUI para ejecutar código en respuesta a cambios de valor. Observa una propiedad concreta y dispara un closure cada vez que su contenido varía. Para determinar si el valor ha cambiado, el tipo observado debe conformar Equatable, lo que permite a SwiftUI realizar la comparación de forma automática y eficiente. Desde su introducción en iOS 14 hasta las revisiones de iOS 17, la API ha madurado considerablemente, y conocer sus variantes actuales marca la diferencia entre código predecible y comportamientos inesperados.
⚙️ La API original de iOS 14 recibía el nuevo valor como parámetro en el closure. Era funcional, pero acceder al valor anterior requería capturarlo explícitamente en la lista de captura, un patrón que resultaba poco intuitivo y propenso a confusión. Estas variantes quedaron deprecadas en iOS 17, aunque el compilador todavía las acepta para mantener la retrocompatibilidad.
// ⚠️ API deprecada en iOS 17 — evitar en código nuevo
struct PlayerView: View {
@State private var volume: Double = 0.5
var body: some View {
Slider(value: $volume)
.onChange(of: volume) { newVolume in
AudioEngine.shared.setVolume(newVolume)
}
}
}
🆕 Desde iOS 17, onChange() ofrece dos contratos más modernos que resuelven las limitaciones anteriores. La primera variante recibe un closure sin parámetros: cualquier lectura del valor observado dentro del closure ya refleja el estado nuevo, porque el modificador se ejecuta tras aplicar el cambio al árbol de vistas. La segunda variante proporciona explícitamente tanto el valor anterior como el nuevo, útil cuando la lógica depende de la transición entre estados.
// ✅ Variante sin parámetros: lee el valor actualizado directamente
struct DownloadView: View {
@State private var downloadedBytes: Int = 0
var body: some View {
ProgressView(value: Double(downloadedBytes), total: 100_000)
.onChange(of: downloadedBytes) {
if downloadedBytes >= 100_000 {
NotificationCenter.default.post(name: .downloadCompleted, object: nil)
}
}
}
}
// ✅ Variante con oldValue y newValue: para lógica basada en la transición
struct AuthView: View {
@State private var authState: AuthState = .unauthenticated
var body: some View {
ContentRouter(state: authState)
.onChange(of: authState) { oldState, newState in
Analytics.shared.trackTransition(from: oldState, to: newState)
}
}
}
⚠️ Existe una trampa importante al migrar de la API antigua a la nueva: el comportamiento de la lista de captura se invierte. En la versión de iOS 14, capturar el valor observado en [value] daba acceso al valor anterior. En la versión moderna, la misma captura da acceso al valor nuevo, porque el closure ya se ejecuta con el estado actualizado. Un detalle que no genera ningún aviso del compilador pero cambia la semántica del código de forma silenciosa.
🔁 Por defecto, onChange() no se dispara en el renderizado inicial de la vista: solo reacciona a cambios posteriores. El parámetro initial, introducido también en iOS 17, modifica este comportamiento. Con initial: true, el closure se ejecuta una vez de forma inmediata con el valor actual, y después en cada cambio sucesivo. En la variante de dos parámetros, esa primera ejecución recibe el mismo valor en oldValue y newValue, precisamente porque no existe estado anterior con el que comparar. Esto permite distinguir entre la llamada inicial y las subsiguientes con una simple comprobación de igualdad.
// ✅ Usando initial: true para configurar estado al aparecer la vista
struct PlaylistView: View {
@State private var selectedTrack: Track = .defaultTrack
var body: some View {
TrackList(selectedTrack: $selectedTrack)
.onChange(of: selectedTrack, initial: true) { oldTrack, newTrack in
if oldTrack == newTrack {
// Primera ejecución: configuración inicial del reproductor
AudioPlayer.shared.prepare(track: newTrack)
} else {
// Cambio real: transición entre pistas
AudioPlayer.shared.crossfade(to: newTrack)
}
}
}
}
🏛️ onChange() existe tanto como modificador de View como de Scene, y la distinción no es cosmética: responde a ciclos de vida distintos. El modificador de vista vive y muere con la vista a la que está adjunto. Es apropiado para reaccionar a estado local, bindings o valores de entorno relevantes para una sección concreta de la interfaz de usuario. El modificador de escena, en cambio, persiste durante todo el ciclo de vida de la escena, lo que lo convierte en el lugar correcto para observar valores de ámbito global como scenePhase.
// ✅ Modificador de escena para persistir datos al pasar a segundo plano
@main
struct JournalApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
PersistenceController.shared.saveContext()
}
}
}
}
🧵 El closure de onChange() puede ejecutarse en el hilo principal, lo que significa que cualquier tarea costosa dentro de él bloqueará la interfaz de usuario. La solución correcta es lanzar un Task desde el closure para desplazar el trabajo a un contexto asíncrono, aprovechando la concurrencia de Swift. El closure en sí sigue siendo síncrono, pero el Task se encarga de ejecutar el código pesado fuera del hilo principal.
// ✅ Trabajo asíncrono dentro de onChange mediante Task
struct SyncView: View {
@State private var isSyncEnabled: Bool = false
var body: some View {
Toggle("Sincronización automática", isOn: $isSyncEnabled)
.onChange(of: isSyncEnabled) {
Task {
await SyncManager.shared.updateSyncPolicy(enabled: isSyncEnabled)
}
}
}
}
📐 Una consideración arquitectural importante: onChange() está pensado para efectos secundarios, no para transformar datos. Si lo que necesitas es derivar un valor a partir de otro, una propiedad computada o un Publisher de Combine son opciones más adecuadas. Usar onChange() para mutar un @State en respuesta a otro @State crea dependencias implícitas que dificultan el seguimiento del flujo de datos y pueden producir ciclos de renderizado difíciles de diagnosticar. La regla práctica es que el closure de onChange() debería comunicarse con el mundo exterior —una capa de dominio, un servicio de audio, un gestor de persistencia— pero no reorganizar el estado interno de la vista.
🔬 Internamente, SwiftUI evalúa el modificador onChange() durante la fase de actualización del árbol de vistas, después de que el nuevo valor haya sido confirmado. Esto garantiza coherencia: cuando el closure se ejecuta, el resto de la vista ya refleja el estado actualizado. Esta secuencia es la razón por la que la variante sin parámetros puede leer el valor observado directamente y obtener siempre el dato más reciente, sin necesidad de recibirlo como argumento.