Absolutamente todas (o casi todas) las personas de desarrollo de frontend que trabajamos con React nos hemos visto alguna vez en la pesadilla de la gestión de estado de nuestra aplicación. La complejidad de esta gestión, una escalabilidad mejorable y diversos factores más, nos pueden arrastrar a un “render hell” del que es muy difícil salir.

Por supuesto, no hay que señalar a Redux o la Context API como culpables de estos problemas, pues si son implementadas de manera correcta, bien pensada y de forma escalable, no tenemos por qué vernos en situaciones así. Al final, lo más importante es tener bien pensada la estructura y la arquitectura de la gestión del estado antes de empezar a picar código.

En estos últimos años han surgido diversas librerías y muchas de ellas han conseguido mucha popularidad (amplia comunidad, un número considerable de descargas en npm, etc), tales como Zustand o Recoil.

Pero una de las más recientes, y la que vamos a ver en este post, es Jotai (traducción de la palabra “átomo” al japonés) y, como su nombre indica, nos promete simplificar esta gestión hasta un nivel atómico.

¿Qué es Jotai?

Para entender Jotai, debemos familiarizarnos con el término “átomo” como unidad mínima de estado, y que todo lo que vamos a escalar surge a partir de ellos (bastante simbólica la comparación). Esta estrategia es llamada atomic state management y busca que React solo renderice los componentes que dependen de cada átomo concreto cada vez que este cambia, intentando eliminar todo re-render innecesario.

Sus principales características son:

Todo esto suena ideal para aplicaciones pequeñas o medianas, donde la gestión de estado debe ser algo mínimo y donde Redux queda muy grande (y sería matar moscas a cañonazos). También en los casos en que usar la Context API podría limitar el boilerplate y la reactividad que se busca en una aplicación pequeña.

Y sí, Jotai brilla especialmente en este tipo de aplicaciones, pero escala muy bien y tiene mucho que ofrecer en aplicaciones grandes.

Átomos y tipos

Existen diferentes tipos de átomos en Jotai. Vamos a verlos en detalle.

Átomos primitivos

En primer lugar, tenemos los átomos primitivos. Estos pueden ser de cualquier tipo: boolean, number, string, objetos, arrays, etc. Los declaramos en un fichero independiente, ya sean ficheros de átomos por entidad, en un directorio atoms/ dentro de source… como queramos definir nuestra arquitectura, aunque el estándar parece ser así.

Vemos aquí un ejemplo de ellos.

import { atom } from "jotai";
const countAtom = atom(0);
const expandedSidebarAtom = atom(false);<br>

Tenemos disponibles una serie de hooks para usar estos átomos dentro de nuestros componentes. El uso de unos u otros dependerá de si vamos a hacer lectura y escritura en el componente o si solo vamos a hacer una de las dos. Si vamos a hacer lectura y escritura en componentes separados, tenemos opción de separar esto para optimizar los re-renders.

Si vamos a hacer lectura y escritura en el mismo componente:

import { counterAtom } from "../atoms/generic";

const Counter: React.FC = 0) = {
   const [count, setCount] = useAtom(counterAtom);
   const handleClick = 0) => {
      setCount((c) => c + 1);
};
   return (
      <button onClick={handleClick}>
        Count: {count}

      </button>
   )
}

Al igual que el useState, el hook de useAtom nos provee tanto del valor del átomo como de su setter. Y al igual que el setter de useState, tenemos el valor previo como atributo del callback que este espera. La diferencia es que este counterAtom es compartido.

Así de simple: un hook, un estado y una reactividad automática, pues los componentes que usasen este counterAtom están suscritos a su valor.

Si la lectura y escritura son en componentes diferentes, tenemos estos hooks:

import { expandedSidebarAtom } from "…/atoms/generic";
import { useAtomValue, useSetAtom } from "jotai";

const SidebarContent = () => {
  const isExpanded = useAtomValue(expandedSidebarAtom)
  return <div className={isExpanded ? 'sidebar-expanded' : 'sidebar-collapsed'}>Menu</div>
};

const ToggleButton = () => {
  const setExpanded = useSetAtom(expandedSidebarAtom)
  const handleToggleButtonClick = () => {
    setExpanded (prev => !prev);
  }
  return <button onClick={handleToggleButtonClick}>Toggle</button>
}

Átomos derivados

Si queremos seguir con la metáfora de la química, podemos decir que un átomo derivado es como una molécula, combinas átomos para crear algo nuevo. No se me ocurre mejor ejemplo para explicarlo que el siguiente:

import { atom } from "jotai";

const hydrogenAtom = atom("H");
const oxygenAtom = atom("O");
const waterAtom = atom((get) => '${get(hydrogenAtom)}₂${get(oxygenAtom)}'); 

En resumen, podemos leer valores de otros átomos antes de retornar su propio valor. Quizás con mi ejemplo metafórico del agua no se ve demasiado útil, pero tal vez con un ejemplo de un formulario mejore la cosa.

import { atom } from "jotai";

const usernameAtom = atom("");
const passwordAtom = atom("");
const avatarAtom = atom(null);
const termsAcceptedAtom = atom(false);

// Haremos que el avatar sea opcional y no lo incluiremos
const isValidFormAtom = atom((get) => {
  const username = get(usernameAtom);
  const password = get(passwordAtom);
  const termsAccepted = get(termsAcceptedAtom);
  return username.length >= 3 && password.length >= 6 && termsAccepted;
});

Vemos que este tipo de átomos se declaran con un callback en lugar del valor, un callback que recibe un getter para el resto de átomos. Esto recuerda enormemente a los selectores de otras librerías como Redux y Recoil. Estos son los que llamamos read-only atoms.

De la misma forma que existen write-only atoms, átomos derivados cuya función podría recordar, en uso, a un reducer de los slice de Redux (salvando diferencias conceptuales de estado inmutable, dispatch, etc).

const notificationsAtom = atom([])

const addNotificationAtom = atom(
  null,
  (get, set, message) => {
    const newNotification = {
      id: Date.now(),
      message,
      timestamp: new Date()
    }
    set(notificationsAtom, [...get(notificationsAtom), newNotification])
  }
)

// Y luego en el componente

const Component= () => {
  const addNotification = useSetAtom(addNotificationAtom)

  return (
    <button onClick={() => addNotification("¡Hola!")}>
      Show notification
    </button>
  )
}

Solo con estos ejemplos ya hemos visto la cantidad de boilerplate que Jotai elimina sin perder funcionalidad.

Con todo esto, se nos abre un mundo de posibilidades para la gestión de estado de nuestra aplicación, pero esto no es todo de Jotai. Contamos con muchas funcionalidades más que harán más completa aún tu experiencia y verás cómo sí que es escalable para casos de uso más grandes, donde tendrás todo (o casi todo) de lo que otras herramientas te ofrecen. Veremos algunos de ellos.

Utilidades

Además del concepto básico de átomos y sus usos, Jotai nos provee de varias utilidades que nos permiten escalar nuestra aplicación hasta niveles superiores sin echar en falta nada de otras librerías.

Veremos las más importantes, destacando que se puede profundizar más en cada una de ellas, y que existen más utilidades y extensiones mantenidas oficialmente por el propio Jotai. No son librerías externas, aunque también existen más allá de las utilidades oficiales.

Storage / persistencia

Existe la posibilidad de crear átomos que almacenen su valor. Este valor se persiste en localStorage o sessionStorage, o en AsyncStorage si estamos en React Native. Para ello disponemos de la función atomWithStorage.

import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";

const languageAtom = atomWithStorage("language", "es");

El átomo que retorna esta función se trata como los que hemos visto anteriormente, con la particularidad de que este valor persistirá en el almacenamiento concreto.

Esta función recibirá los siguientes parámetros:

import { atomWithStorage } from "jotai/utils";

const cartAtom = atomWithStorage("cart'", [], {
  getItem: (key) => {
    const value = sessionStorage.getItem(key)
    return value ? JSON.parse(value) : []
  },
  setItem: (key, value) => {
    sessionStorage.setItem(key, JSON.stringify(value))
  },
  removeItem: (key) => sessionStorage.removeltem(key)
});

Asincronía

El buen manejo de la asincronía es fundamental en el desarrollo frontend moderno, y Jotai nos provee de las herramientas necesarias para ello.

Podemos definir átomos de lectura asíncronos que devuelvan promesas, ya que Jotai tiene un soporte total para asincronía y el Suspense de React. Al consumir cualquier átomo envuelto en Suspense, Jotai gestiona el ciclo de carga automáticamente sin esfuerzo adicional.

Veámoslo con un ejemplo.

Así definimos un átomo asíncrono en Jotai:

import { atom } from "jotai";

export const userAtom = atom(async () => {
  const res = await fetch("/api/user");
  if (!res.ok) throw new Error("No se pudo cargar el usuario");
  return res.json();
});

Y solo tendríamos que consumirlo en nuestro componente, dando por hecho que el valor resultante será lo que esperamos que devuelva la promesa en caso exitoso, y Suspense se encargará de gestionar el estado de carga. Si además lo envolvemos en un ErrorBoundary, tendremos también gestionado el estado de error:

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

const UserProfile = () => {
  const user = useAtomValue (userAtom);

  return (
    <div>
      <h1>{user.name}</h1>
      {user.email}
    </div>
  )
};

const App = () => (
  <ErrorBoundary fallback={<h1>Error cargando usuario</h1>}>
    <Suspense fallback={<div>Cargando...</div>}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
)

¿Qué pasa si no queremos utilizar Suspense? También tenemos herramientas para controlar el estado de carga por nosotros mismos. Siempre podemos gestionar el estado de la promesa que retorna el uso del átomo de la siguiente manera:

if (user instanceof Promise) {
  return <div>Cargando usuario...</div>
}

Pero existen opciones mejores*. Aquí es donde entra en juego la función loadable.
Usaremos esta función a la que proporcionaremos un átomo de lectura asíncrono y nos devolverá el estado de la promesa, los datos y el error (si lo hubiera). Es muy parecido a lo que nos devuelven, por ejemplo, las querys de librerías como SWR o React Query.

import { useAtomValue } from "jotai";
import { loadable } from "jotai/utils";

const userLoadableAtom = loadable(userAtom);

const UserProfile = () = {
  const { state, data, error } = useAtomValue(userLoadabLeAtom);

  if (state === "loading") {
    return <div>Cargando...</div>;
  }

  if (state === "hasError") {
    return <h1>Error: {error.message}</h1>;
  }

  return (
    <div>
      <h1>{data.name}</h1>
      {data.email}
    </div>
  );
};

Bastante útil y muy elegante, con nada de código generado. En este punto, ya hemos cubierto casi todas las necesidades básicas de la gestión de un estado, pero aún tiene muchas más funcionalidades.

Lazy Loading / carga perezosa

Se nos proporcionan también herramientas para la gestión de lazy-loading del valor de algunos átomos. Los átomos se inicializan al principio de todo el ciclo de vida de la aplicación, pero podemos tener el caso de tener alguno que debe cargar datos pesados que tengan mucho coste.

Aquí entra en juego la función atomWithLazy. Esta solo hará que el valor por defecto del átomo se cargue únicamente cuando el primer componente que lo use sea renderizado, no afectando así al rendimiento de carga de la aplicación al inicio si ese componente no es visible de primeras, por ejemplo. En el momento que haya cargado, este átomo se comportará como un átomo primitivo estándar.

import { atomWithLazy } from "jotai/utils";
import { useAtom } from "jotai";

const expensiveDataAtom = atomWithLazy(() => {
  return { data: "Datos muy pesados" };
});

const HomePage = () => {
  return <h1>Home</h1>;
};

const DataPage = () => {
  const data = useAtomValue(expensiveDataAtom);
  return (
    <div>
      <h1>Data Page</h1>
      Content: {data.data}
    </div>
  );
};

const App = () => {
  const [currentPage, setCurrentPage] = useState("home");

  return (
    <div>
      <nav>
        <button onClick={() => setCurrentPage("home")}>Home</button>
        <button onClick={() => setCurrentPage("data")}> Data</button>
      </nav>

      {currentPage === "home" && <HomePage />}
      {currentPage === "data" && <DataPage />}
    </div>
  );
};

En el ejemplo, los datos que se usan en el componente DataPage no serán inicializados hasta que naveguemos a esa página y provoque el renderizado de DataPage.

Existen muchas más funcionalidades extra y extensiones de Jotai, pero por cuestiones lógicas es imposible verlas todas, por lo que si esta lectura te ha parecido interesante hasta el momento, te recomiendo que visites su web oficial y veas todo lo que ofrece.

¿Y en SSR (Server-side rendering)?

Jotai es compatible con algunos frameworks de React enfocados en el SSR como Next.js o Waku. Pero hay una particularidad: se debe usar el provider de Jotai. Esto no es necesario para las spa estándar que podemos crear con Vite o CRA, pero sí será necesario en las apps con SSR.

import { provider } from 'jotai'

Luego, debe envolver la aplicación el RootLayout.

Jotai también provee un hook, useHydrateAtoms, para evitar desajustes entre los componentes renderizados en servidor y en cliente, ya que el cliente puede tener valores diferentes, por ejemplo si se ha usado atomWithStorage en el cliente. Con este hook, nos aseguramos siempre de inicializar átomos del cliente con datos del servidor. Importante recalcar que este hook es de uso único en los componentes client side con la directiva use client.

Ventajas y desventajas de Jotai

Por último, analicemos brevemente las ventajas y desventajas de Jotai vistas en este post.

Ventajas

Desventajas

En conclusión, Jotai es un adversario contundente para las librerías de gestión de estado ya asentadas y para la Context API. Definitivamente aporta mucho con su filosofía atómica, eliminando muchísimo código para hacer cosas simples y relativamente complejas, pero es una librería que aún no lleva tanto tiempo en el mercado como otras, cuya comunidad aún es pequeña y tiene limitaciones evidentes.

Aun así, para spas ligeras con estados pequeños o medianos es ideal, muy buena opción en aplicaciones modulares con microfrontends o con dependencias entre estados derivados. Puede quedarse corta en aplicaciones con flujo de datos complejo y con grandes estructuras de datos interdependientes.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete