Bonsai

Cómo funciona

Estado atómico, celda por celda

Lo que te llamó la atención de xlsheets (átomos creados con una función y un memoize) es un patrón con nombre: estado atómico. Aquí está explicado con Jotai, que usa el mismo modelo que Recoil pero sigue mantenido.

1. El problema

Una hoja de cálculo son miles de valores pequeños que dependen unos de otros. Si cambias A1, tienen que actualizarse las celdas que usan A1, y las que usan a esas, y nada más. Con un estado «normal» (un objeto grande en un useState, un store de Redux…) cualquier cambio avisa a todos los componentes que leen ese objeto, y cada uno tiene que averiguar si le afecta.

El estado atómico le da la vuelta: el estado se parte en piezas diminutas (átomos) y cada componente se suscribe solo a las piezas que lee. Los valores calculados son átomos derivados que recuerdan de qué piezas dependen.

2. Un átomo por celda

Escribe en cualquier celda de los dos paneles. Cada celda muestra cuántas veces se ha renderizado (número gris) y parpadea al hacerlo. A la izquierda, todas las celdas leen el mismo átomo con la hoja entera; a la derecha, cada celda tiene el suyo.

Un solo átomo con toda la hoja

useAtom(wholeSheetAtom) en cada celda

renders totales0

Un átomo por celda

useAtom(cellAtom(id)) en cada celda

renders totales0

Con una tecla, el panel izquierdo vuelve a pintar las 25 celdas; el derecho, solo una. En la hoja real hay ~600 celdas visibles: es la diferencia entre escribir con fluidez o notar el retraso.

3. De Recoil (xlsheets) a Jotai

El código de xlsheets que no se entendía bien era una familia de átomos escrita a mano. Recoil exige una key de texto única por átomo, y por eso hacía falta cachearlos: crear dos veces atom({ key: "cell_0,0" }) da error.

xlsheets · Recoil (abandonado)
const memoized = {};
const memoize = (id, factory) =>
  memoized[id] ??= factory();

export const CellValueState = (cellId) =>
  memoize(cellId, () =>
    atom({
      key: `cell_${cellId}`,
      default: "",
    })
  );
bonsai · Jotai
// state/atomFamily.ts
function atomFamily(create) {
  const cache = new Map();
  return (param) => {
    if (!cache.has(param))
      cache.set(param, create(param));
    return cache.get(param);
  };
}

// state/cells.ts
export const cellInputAtom =
  atomFamily(() => atom(""));
ConceptoRecoilJotai
Pieza de estadoatom({ key, default })atom(valorInicial), sin key
Valor derivadoselector({ key, get })atom((get) => …)
Familia por parámetroatomFamily / selectorFamilyUn Map + función (en Jotai 3 ya no viene incluida)
Dónde viven los valores<RecoilRoot>createStore() + <Provider store>
Leer / escribiruseRecoilValue, useRecoilStateuseAtomValue, useAtom, useSetAtom
EstadoArchivado por Meta en 2025Mantenido (pmndrs), compatible con React 19

Un detalle importante: en Jotai un átomo es solo una definición. No guarda el valor; el valor vive en el store. Por eso cachear las definiciones en un Map global no mezcla datos entre dos hojas: cada una tiene su store.

4. Las capas de una celda

Cada celda no es un átomo, sino una pequeña cadena de ellos. Cada capa solo se recalcula si cambia la anterior:

  1. 1

    cellInputAtom

    El texto tal cual: "=A1*2". Átomo primitivo, el único que se escribe.

  2. 2

    cellParsedAtom

    Número, texto o el árbol (AST) de la fórmula. Solo se re-parsea si cambia el texto.

  3. 3

    cellRefsAtom

    Las referencias como string: "A1|B1:B3". Sirve para detectar ciclos.

  4. 4

    cellValueAtom

    El resultado. Lee los valores de otras celdas con get() y así crea el grafo.

src/features/excel/state/cells.ts (simplificado)
export const cellValueAtom = atomFamily((address: string) =>
  atom((get) => {
    const parsed = get(cellParsedAtom(address));
    if (parsed.kind === "value") return parsed.value;
    if (formsCycle(get, address)) return err("#¡REF!", "Referencia circular");

    return evaluateFormula(parsed.ast, {
      // Cada get() registra una dependencia: "este valor depende de esa celda".
      getCell: (row, col) => get(cellValueAtom(toAddress(row, col))),
    });
  }),
);

El evaluador de fórmulas no sabe nada de Jotai: solo recibe una función getCell. Es el átomo derivado quien decide que getCell sea un get(), y en ese momento Jotai anota la dependencia.

5. El grafo de dependencias

Esta mini hoja usa exactamente los mismos átomos que la app. Edita una celda (la línea gris es el contenido; la de abajo, el valor) y mira cuáles se recalculan. Prueba los cuatro escenarios.

Cambia A1: se recalculan A2 y A3, pero B2 no (no depende de A1).

A
B
C
1
2
5
31
2
20
10
3
21
4

Recalculadas en el último cambio: ninguna

Cada celda tiene un número gris arriba a la derecha: las veces que React la volvió a pintar.

A1B1C1A3B2A2
valorfórmula (átomo derivado)recalculadaflecha = «es leída por»

Dependencias dinámicas

Las dependencias no se declaran: se descubren al evaluar. En =SI(A1>0;B1;C1), mientras A1 sea positivo la fórmula nunca lee C1, así que cambiar C1 no la despierta. Para conseguirlo, SI() recibe sus argumentos como funciones (thunks) y solo evalúa la rama que necesita.

Ciclos: Jotai necesita un grafo sin ciclos

Si A1 = B1 y B1 = A1, un átomo derivado leería a otro que lo lee a él, y Jotai entraría en recursión infinita. La primera idea (marcar «estoy evaluando A1» y cortar si vuelve a aparecer) falla en un caso sutil: si el ciclo se cierra usando valores ya cacheados, nadie se re-evalúa y el grafo interno queda con un ciclo. La solución: antes de evaluar, recorrer las referencias estáticas leyendo solo cellRefsAtom, que depende del texto y nunca de valores. Es un string, así que cambiar un número no despierta a nadie.

6. Acciones: átomos de solo escritura

Los cambios no se hacen con set(cellInputAtom(…)) repartidos por los componentes. Hay átomos «acción» que reciben una orden y deciden qué escribir. Así el historial de deshacer vive en un solo sitio.

src/features/excel/state/history.ts (simplificado)
export const applyChangesAtom = atom(null, (get, set, writes: CellWrite[]) => {
  const before = writes.map((w) => ({
    address: w.address,
    input: get(cellInputAtom(w.address)),
  }));
  set(writeCellsAtom, writes);           // la única puerta para escribir celdas
  set(undoStackAtom, (s) => [...s, { before, after: writes }]);
  set(redoStackAtom, []);
});

export const undoAtom = atom(null, (get, set) => {
  const entry = get(undoStackAtom).at(-1);
  if (entry) set(writeCellsAtom, entry.before);
  // …mover la entrada a la pila de rehacer
});

Escribir, borrar, pegar, dar formato o Ctrl+Enter pasan por applyChangesAtom. Deshacer es reescribir el «antes».

7. Store y Provider

src/features/excel/components/Spreadsheet.tsx
export default function Spreadsheet() {
  // Cada hoja crea SU store (y carga lo guardado en localStorage).
  const [store] = useState(createWorkbookStore);
  return (
    <Provider store={store}>
      <Workbook />
    </Provider>
  );
}

Tener el store a mano permite usarlo fuera de React: el autoguardado hace store.sub(docVersionAtom, …), y los manejadores de teclado leen con store.get sin provocar renders. Las demos de esta página usan stores distintos y por eso no tocan tu libro.

8. Lo que NO va en átomos por celda

Que el estado sea atómico no significa que todo deba serlo. Varias decisiones de la hoja van en contra de la intuición:

  • La selección es una capa encima

    Si cada celda supiera «estoy seleccionada», mover la selección pintaría cientos de celdas. En su lugar, un único componente dibuja el borde verde en coordenadas absolutas.

  • Encabezados con átomos derivados por columna

    colHeaderStateAtom(col) se recalcula para todas las columnas al mover la selección (es barato), pero React solo repinta las que cambian de resaltado.

  • Virtualización

    La hoja tiene 52.000 celdas; en el DOM solo existen las visibles. Las celdas que salen de pantalla dejan de estar suscritas a sus átomos.

  • Índice de celdas usadas

    usedCellsAtom guarda qué celdas tienen algo. La barra de estado, Ctrl+Fin, el guardado y el CSV recorren ese índice en vez de toda la hoja.

  • Referencias al DOM fuera de Jotai

    El foco, el input activo o el contenedor con scroll no son estado de la app: viven en un objeto compartido por contexto y no causan renders.

9. El motor de fórmulas

xlsheets reemplazaba texto ("A1" por su valor) y le pasaba el resultado a mathjs. Aquí la fórmula pasa por tres etapas, como en un compilador pequeño. Escribe y míralas:

Valores de ejemplo: A1=10, A2=20, A3=30, B1=5, B2="hola", C1=VERDADERO

1TokenizerEl texto se corta en piezas con tipo.

SUMAfunction(parenA1ref:colonA3ref)paren*operator2number

2Parser → ASTLas piezas se organizan en un árbol según la precedencia.

  • operador *
    • función SUMA()
      • rango A1:A3
    • número 2

3EvaluadorSe recorre el árbol. Cada referencia llama a getCell(), que en la hoja es get(átomo).

120

10. Los bugs de xlsheets y cómo se evitan

En xlsheetsQué pasabaAquí
replaceAll("A1", valor)=A1+A10 también reemplazaba el «A1» de «A10»Tokenizer: A10 es un token entero. Hay un test para este caso.
El efecto devolvía addEventListenerCada render añadía otro listener de clic, sin quitarlo nuncaLos listeners de arrastre se quitan en mouseup; el resto son eventos de React
Ciclos con expression.includes(id)Falsos positivos (A1 dentro de A10) y ciclos indirectos no detectadosDFS sobre referencias parseadas, sin crear ciclos en Jotai
value || 0 y luego .startsWithError tapado por un catch {} vacíoTipos explícitos (Scalar) y errores como valores (CellError)
Caché global de memoizeMezclaba la definición con el valor (Recoil)En Jotai el Map guarda definiciones; los valores van en el store

11. Mapa de archivos

src/features/excel/
├─ engine/              TypeScript puro, sin React ni Jotai (con tests)
│  ├─ address.ts        "B12" <-> { row, col }
│  ├─ tokenizer.ts      texto -> tokens
│  ├─ parser.ts         tokens -> AST (Pratt)
│  ├─ evaluator.ts      AST -> valor (recibe getCell)
│  ├─ functions.ts      SUMA, PROMEDIO, SI, SUMAR.SI…
│  ├─ values.ts         tipos, errores y conversiones
│  ├─ cellInput.ts      qué escribió el usuario, desplazar referencias
│  └─ format.ts         cómo se muestra un número
├─ state/               Jotai
│  ├─ atomFamily.ts     el "memoize" bien hecho
│  ├─ cells.ts          átomos por celda + writeCellsAtom
│  ├─ selection.ts      selección y encabezados
│  ├─ editing.ts        borrador, modo Señalar, confirmar
│  ├─ history.ts        deshacer / rehacer
│  ├─ actions.ts        navegar, formato, copiar/pegar
│  ├─ sheet.ts          anchos, altos y posiciones
│  └─ workbook.ts       crear store, guardar, ejemplo
├─ components/          la interfaz (Grid, Cell, FormulaBar, Ribbon…)
└─ learn/               las demos de esta página