Actualizadores en la ruta: derivar del valor previo
Cuando el valor nuevo depende del anterior, el último argumento de setStore puede ser una función que recibe el valor actual en esa hoja y devuelve el siguiente. Es el equivalente del actualizador funcional del signal, pero situado en cualquier profundidad del árbol. Evita lecturas externas del proxy, elimina cierres obsoletos y hace atómica cada transición.
Incrementar un contador, alternar una bandera, añadir a una lista: son cambios donde el estado siguiente es una función del actual, no un valor caído del cielo. Podrías leer el store, calcular fuera y volver a escribir, pero esa maniobra en tres tiempos abre grietas —lecturas obsoletas, condiciones de carrera sutiles, dependencias que no querías— que Solid te ahorra con un solo gesto: donde iría el valor final, pon una función. El setter la llamará con lo que hay en esa hoja y escribirá lo que devuelvas.
- Usar una función como último argumento de
setStorepara derivar del valor previo. - Situar el actualizador en cualquier profundidad del árbol, no solo en la raíz.
- Entender por qué derivar dentro del setter evita lecturas y cierres obsoletos.
- Reconocer qué devuelve el actualizador y cuándo su retorno se fusiona.
El último argumento como función
Ya sabes que setStore(...ruta, valor) navega hasta una hoja y escribe. La regla se completa así: si ese último argumento es una función, Solid la invoca con el valor actual de la hoja y usa lo que devuelve como valor nuevo. El caso canónico cabe en una línea:
import { createStore } from "solid-js/store";
const [estado, setEstado] = createStore({ contador: 0, abierto: false });
setEstado("contador", (c) => c + 1); // incrementa leyendo el previo
setEstado("abierto", (o) => !o); // alterna la bandera
El actualizador (c) => c + 1 no lee estado.contador por su cuenta: lo recibe como argumento, ya resuelto y fresco, directamente de las manos del setter. La transición «lo que había, más uno» queda expresada como una sola operación indivisible, sin una variable temporal que envejezca entre la lectura y la escritura.
Compáralo con la alternativa ingenua, que parece equivalente y no lo es:
// Fragil: lee fuera, calcula, escribe. Tres tiempos, un valor que puede envejecer
setEstado("contador", estado.contador + 1);
Aquí estado.contador + 1 se evalúa antes de que el setter corra, capturando el valor en ese instante. Dentro de un lote, dentro de un efecto, o si dos de estas escrituras se encadenan, ese número puede haber quedado atrás. El actualizador no puede envejecer porque recibe el valor en el momento exacto de la escritura, no un instante antes.
batch(() => {
setEstado("contador", (c) => c + 1);
setEstado("contador", (c) => c + 1); // parte del +1 anterior: termina en +2
});
// Con estado.contador + 1 repetido, ambas verian el mismo valor: solo subiria +1
Ese es el escenario donde la diferencia deja de ser teórica: dos incrementos encadenados que con lectura externa colapsan en uno, y con el actualizador se acumulan correctamente porque cada uno ve lo que dejó el anterior.
Actualizadores a cualquier profundidad
La potencia real llega al combinar la ruta con el actualizador: la función no vive solo en la raíz, sino en la hoja a la que llegues, por honda que esté. Los argumentos previos te llevan; el último deriva.
const [estado, setEstado] = createStore({
carrito: { total: 0, lineas: { impuesto: 0 } },
});
setEstado("carrito", "total", (t) => t + 9.99);
setEstado("carrito", "lineas", "impuesto", (i) => +(i * 1.21).toFixed(2));
La función recibe siempre el valor de esa hoja, no del estado entero: (t) => t + 9.99 ve el número que hay en carrito.total, aislado. El actualizador no conoce ni necesita conocer el resto del árbol; opera sobre su hoja como una función pura de un valor a otro, lo que lo hace trivial de extraer, nombrar y reutilizar.
Y hay un segundo argumento que pocos usan y conviene conocer: la ruta recorrida hasta la hoja. Es útil cuando un mismo callback se aplica en varias posiciones y necesita saber dónde está actuando.
// El actualizador recibe (valorActual, rutaRecorrida)
const incrementar = (v: number, ruta: (string | number)[]) => {
console.debug("escribiendo en", ruta.join("."));
return v + 1;
};
setEstado("carrito", "total", incrementar); // ruta: carrito.total
Esa segunda pieza convierte a un actualizador genérico en uno consciente de su contexto sin acoplarlo a una ruta fija: la lógica de «suma uno» vive en un sitio y se aplica allá donde la coloques, informándose de su posición solo si le importa.
flowchart LR A[setStore carrito total fn] --> B[ruta lleva a la hoja total] B --> C[el setter lee el valor actual] C --> D[llama fn con ese valor] D --> E[escribe lo que fn devuelve] E --> F[notifica solo a lectores de total] style C fill:#89b4fa,color:#11111b style D fill:#cba6f7,color:#11111b style F fill:#a6e3a1,color:#11111b
Para una bandera booleana, setStore("ruta", "abierto", (o) => !o) es la forma canónica de invertirla sin conocer su estado previo desde fuera. Nunca escribas setStore("abierto", !estado.abierto): repites la lectura externa que el actualizador existe para eliminar, y en cadenas o lotes puedes alternar contra un valor ya caduco.
Escribir sin suscribirse
Leer fuera y escribir
setStore("n", estado.n + 1). Capturas el valor un instante antes de escribir; en cadenas, lotes y asincronía puede haber envejecido, y dentro de un ámbito reactivo lo suscribes sin querer.
Derivar dentro
setStore("n", (n) => n + 1). Recibes el valor en el latido exacto de la escritura, sin lectura externa ni dependencia accidental. La transición es indivisible y siempre parte del presente.
Hay un beneficio silencioso en derivar dentro del setter que solo se revela dentro de un ámbito reactivo. Leer estado.contador en el cuerpo de un efecto o un memo suscribe ese ámbito a la hoja: desde ese instante, cada cambio del contador vuelve a ejecutarlo. El actualizador, en cambio, recibe el valor por argumento sin tocar el proxy, así que escribe sin crear dependencia alguna.
createEffect(() => {
registrar(estado.evento); // esto SI suscribe: reaccionas a evento
// Si leyeras estado.contador aqui para incrementarlo, el efecto
// se suscribiria a si mismo: un bucle o re-ejecuciones de mas
setEstado("contador", (c) => c + 1); // no lee, no se suscribe
});
El caso patológico es el bucle accidental: un efecto que lee un campo y lo escribe se convierte en su propia fuente y se re-dispara sin fin. Con el actualizador el peligro desaparece de raíz, porque escribir el contador ya no implica leerlo. Esta es la razón profunda por la que la comunidad de Solid prefiere (c) => c + 1 incluso fuera de efectos: no es solo elegancia, es que mantiene tu grafo de dependencias limpio de aristas que nunca quisiste dibujar.
Qué devuelve el actualizador
El valor que retorna la función entra en el store con exactamente las mismas reglas que un valor literal en esa posición, y esto encierra una sutileza que conviene tener clara. Si devuelves una primitiva o un array, reemplaza la hoja. Si devuelves un objeto y en esa posición ya había un objeto, Solid lo fusiona superficialmente.
// Devuelve objeto sobre objeto -> se FUSIONA, no reemplaza
setEstado("usuario", (u) => ({ edad: u.edad + 1 }));
// nombre y demas claves sobreviven aunque no las menciones
// Devuelve array -> REEMPLAZA la referencia entera
setEstado("etiquetas", (e) => [...e, "nueva"]);
De aquí sale una consecuencia que sorprende a quien viene de reducers inmutables: al devolver un objeto desde un actualizador de store, esparcir ...u para conservar las claves intactas es redundante, porque la fusión ya las conserva. (u) => ({ edad: u.edad + 1 }) basta. El spread no hace daño, pero delata que aún piensas en términos de reemplazo total cuando el store razona en parches. Los arrays, en cambio, no se fusionan: ahí el spread sí es necesario, porque devolver un array reemplaza la referencia entera y debes construir el contenido completo del nuevo.
Si tu actualizador devuelve exactamente el valor que recibió, Solid detecta que la referencia no cambió y no notifica. Puedes explotarlo como guarda: setStore("x", (v) => condicion ? nuevo : v) escribe solo cuando la condición se cumple y, si no, deja el store —y a sus lectores— en calma, sin una sola propagación desperdiciada.
El actualizador funcional parece un mero atajo para no teclear el nombre del store dos veces, pero lo que en realidad resuelve es un problema de tiempo. Toda transición de estado que depende del estado anterior es, en el fondo, una secuencia de tres actos: leer lo que hay, calcular lo que sigue, escribir el resultado. Cuando esos tres actos viven separados en tu código —lees estado.contador, sumas uno, lo escribes— introduces un intervalo entre la lectura y la escritura en el que el mundo puede cambiar: otra escritura encadenada, la propagación de un lote, un actualizador que corre después de que capturaste el valor. En ese intervalo tu número envejece, y escribes basándote en un pasado que ya no es el presente. El actualizador colapsa los tres actos en uno indivisible: el setter lee la hoja y te la entrega en el mismo latido en que va a escribir tu resultado, de modo que entre lo que ves y lo que grabas no cabe ningún otro suceso. No es casual que sea la misma forma del setter de un signal —set(prev => next)— reubicada en una hoja del árbol: es el mismo principio de que el estado siguiente se declara como una función del actual, no como un dato externo que tuviste que ir a buscar. Y hay un beneficio de segundo orden que la madurez enseña a valorar: al no leer el proxy por fuera, no creas dependencias accidentales. Leer estado.contador dentro de un ámbito reactivo lo suscribe; recibir c como argumento del actualizador, no. Así que derivar dentro del setter no solo te protege de valores caducos, también mantiene limpio tu grafo de dependencias, escribiendo sin suscribirte a lo que escribes. Cuando interiorizas esto dejas de preguntarte si es seguro leer fuera y adoptas una regla sin excepciones: si el valor nuevo mira al viejo, el viejo entra por el argumento, jamás por una lectura suelta.
- Incrementa un contador del store con
(c) => c + 1y confirma que funciona sin leer el proxy por fuera. - Encadena tres incrementos seguidos dentro de un
batchy verifica que el contador sube tres, no uno; luego intenta la versión con lectura externa y observa la diferencia. - Sitúa un actualizador en una hoja a tres niveles de profundidad y comprueba que recibe solo el valor de esa hoja.
- Devuelve un objeto parcial desde un actualizador sobre un subobjeto y confirma que las claves no mencionadas sobreviven sin
spread. - Escribe una guarda
(v) => condicion ? nuevo : vy verifica con un efecto que, cuando la condición es falsa, no hay ninguna notificación.