Absolutely all (or almost all) frontend developers working with React have at some point experienced the nightmare of application state management. The complexity of managing state, the challenges of scalability, and various other factors can drag us into a “render hell” that's tough to escape.

Of course, we shouldn't blame Redux or the Context API for these issues—when they're implemented correctly, with careful planning and scalability in mind, there's no reason to end up in such situations. Ultimately, the most important thing is to carefully design the structure and architecture of your state management before you start coding.

In recent years, several libraries have emerged and gained popularity (growing communities, strong npm download numbers, etc.), such as Zustand and Recoil.

But one of the newest contenders, and the one we’ll explore in this post, is Jotai (the Japanese word for “atom”), and as the name suggests, it aims to simplify state management down to an atomic level.

What is Jotai?

To understand Jotai, we first need to understand the term “atom” as the smallest unit of state, from which all scalable structures are built (a symbolic yet fitting metaphor). This strategy is known as atomic state management, and its goal is to ensure React only re-renders the components that depend on a specific atom whenever that atom changes, avoiding unnecessary re-renders.

Its main features include:

All of this makes it ideal for small to medium-sized applications, where state management should remain simple, and where using Redux might feel like overkill. It also works well in cases where the Context API could become limiting due to boilerplate or reduced reactivity in a small app.

And yes, Jotai truly shines in these kinds of apps, but it also scales surprisingly well and has plenty to offer in larger applications.

Atoms and types

There are different types of atoms in Jotai. Let’s take a closer look at them.

Primitive atoms

First, we have primitive atoms. These can be of any type: boolean, number, string, object, arrays, etc. They’re typically declared in separate files, whether organized per entity or within an atoms/ directory inside src, depending on how you choose to structure your app. That said, this seems to be the standard approach.

Here’s an example of primitive atoms:

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

We have a series of hooks available to use these atoms inside our components. Which hook you choose will depend on whether you’re performing both reading and writing in the component, or only one of the two. If you’re splitting reading and writing across separate components, you also have the option to separate these operations to optimize re-renders.

If you’re going to perform both reading and writing in the same component:

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>
   )
}

Just like useState, the useAtom hook provides both the value of the atom and its setter. And just like the useState setter, you get the previous value as an argument in the callback it expects. The difference here is that this counterAtom is shared.

It’s that simple: one hook, one state, and automatic reactivity, since any components using this counterAtom are subscribed to its value.

If reading and writing are in different components, we have these 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>
}

Derived Atoms

If we stick to the chemistry metaphor, we could say that a derived atom is like a molecule — you combine atoms to create something new. I can’t think of a better example to explain it than the following:

import { atom } from "jotai";

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

In summary, we can read values from other atoms before returning our own value. Maybe my metaphorical water example doesn’t seem too useful, but perhaps a form-related example will make things clearer.

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;
});

We can see that this type of atom is declared with a callback instead of a value—a callback that receives a getter for accessing other atoms. This strongly resembles selectors from other libraries like Redux and Recoil. These are what we call read-only atoms.

In the same way, there are write-only atoms, which are derived atoms whose function may resemble a Redux slice reducer in practice (with some conceptual differences such as immutable state, 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])
  }
)

// And then in the component

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

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

Just with these examples, we’ve already seen the amount of boilerplate Jotai eliminates without sacrificing functionality.

All of this opens up a world of possibilities for managing our application’s state—but this is not all Jotai has to offer. It comes packed with many more features that enhance your experience and prove how scalable it really is for larger use cases, providing (almost) everything other tools offer. Let’s look at some of them.

Utilities

In addition to the basic concept of atoms and their uses, Jotai provides a number of utilities that allow us to scale our applications to a higher level without missing features from other libraries.

We’ll cover the most important ones here, though it's worth noting that each can be explored in more depth, and there are additional utilities and extensions officially maintained by the Jotai team. These are not external libraries, although there are also community-maintained ones beyond the official utilities.

Storage / persistence

Jotai allows you to create atoms that store their value persistently. This value can be saved in localStorage, sessionStorage, or in AsyncStorage if you're working with React Native. For this, Jotai provides the atomWithStorage utility.

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

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

The atom returned by this function works just like the ones we've seen before, with the special feature that its value will persist in the selected storage.

This function takes the following parameters:

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)
});

Asynchrony

Proper asynchrony handling is essential in modern frontend development, and Jotai provides the necessary tools for it.

We can define asynchronous read atoms that return promises, since Jotai offers full support for asynchrony and React’s Suspense. When consuming any atom wrapped in Suspense, Jotai automatically manages the loading cycle without extra effort.

Let’s see an example.

Here’s how to define an asynchronous atom in 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();
});

And we would only need to consume it in our component, assuming that the resulting value will be what we expect the promise to return on success, and Suspense will handle the loading state. Additionally, if we wrap it in an ErrorBoundary, we’ll also have the error state managed:

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 loading user</h1>}>
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
)

What if we don’t want to use Suspense? We also have tools to manage the loading state ourselves. We can always handle the state of the promise returned by the atom like this:

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

But there are better options. This is where the loadable function comes into play. We use this function by providing it with an asynchronous read atom, and it returns the state of the promise, the data, and the error (if any).

It’s very similar to what we get from queries in libraries like SWR or 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>Loading...</div>;
  }

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

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

Quite useful and very elegant, with no generated code at all. At this point, we’ve already covered almost all the basic state management needs, but Jotai still has many more features to offer.

Lazy Loading

We are also provided with tools for handling the lazy-loading of atom values. Atoms are typically initialized at the beginning of the application’s lifecycle, but there may be cases where some of them need to load heavy data that comes with a performance cost.

This is where the atomWithLazy function comes into play. It ensures that the default value of the atom is loaded only when the first component that uses it is rendered, thus avoiding a performance hit during the initial load if that component isn't visible right away, for example.

Once it has been loaded, the atom behaves like a standard primitive atom.

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

const expensiveDataAtom = atomWithLazy(() => {
  return { data: "large data files" };
});

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>
  );
};

In the example, the data used in the DataPage component won't be initialized until we navigate to that page and trigger its rendering.

There are many more extra features and extensions available in Jotai, but for obvious reasons, it's impossible to cover them all here. So if you've found this article interesting so far, I highly recommend visiting the official website to explore everything Jotai has to offer.

What about SSR (Server-side rendering)?

Jotai is compatible with some React frameworks focused on SSR, such as Next.js or Waku. However, there is one key requirement: you must use Jotai’s provider.

This isn’t necessary for standard SPAs created with tools like Vite or CRA, but it is required in SSR apps.

import { provider } from 'jotai'

Then, you should wrap the application with the RootLayout.

Jotai also provides a hook called useHydrateAtoms, which helps prevent mismatches between components rendered on the server and the client. This is particularly useful when using atoms like atomWithStorage that may have different values on the client side. With this hook, we ensure that client atoms are initialized with server data. It's important to note that this hook must be used only inside client-side components and requires the use client directive.

Pros and Cons of Jotai

Let’s briefly analyze the advantages and disadvantages of Jotai covered in this post.

Pros

Cons

In conclusion, Jotai is a strong contender against well-established state management libraries and the Context API. It definitely brings a lot to the table with its atomic philosophy, removing a lot of boilerplate for both simple and moderately complex scenarios. However, it's still relatively new compared to other libraries, with a smaller community and some obvious limitations.

That said, it’s an ideal choice for lightweight SPAs with small to medium state, and a great fit for modular applications using microfrontends or derived state dependencies. It might fall short in applications with complex data flows and large, interconnected data structures.

References

Tell us what you think.

Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.

Subscribe