Skip to content

Jotai Recipes I Commonly Use for My Project

On this page

Introduction

Jotai takes an atomic approach to global React state management. You build state by combining atoms, and renders are optimized automatically based on atom dependencies.

This solves the extra re-render issue of React context, removes the need for memoization, and gives you a developer experience similar to signals, all while keeping a declarative programming model.

If you’re new to Jotai, I wrote a post explaining Jotai. Feel free to check it out!

To update state with Jotai, I can use the built-in setter like so:

app.tsx

import { atom, useAtom } from "jotai";

const atomFruit = atom("mango");

export default function App() {
  const [fruit, setFruit] = useAtom(atomFruit);

  return <button onClick={() => setFruit("orange")}>{fruit}</button>;
}

That’s basic Jotai usage. Pretty simple.

Derived Value

Before we get to recipes in Jotai, we need to know what a derived or computed value is.

A derived or computed value is a value you get from other existing values through some logic, transformation, or calculation.

In programming, derived values are often used to simplify code, encapsulate logic, and make it more convenient to work with data based on existing values.

App.tsx

export default function App() {
  const { user } = useUser();
  const isUserAdmin = user.role === "admin";

  if (isUserAdmin) {
    return <p>You are admin</p>;
  }

  return <p>Just a normal user</p>;
}

In the example above, we check whether the user is an admin. If they are, the component renders “You are admin”. Otherwise, it renders “Just a normal user”.

The condition that decides which UI to render is the derived value isUserAdmin.

Oh wow! We’re already using a derived value here.

Derived Atoms

Let’s move on to derived atoms. They work just like the previous example: we look at the existing values, then work some magic with our logic or calculation.

@/atoms.ts

import { atom } from "jotai";

type TUserRole = "admin" | "user";
type TUser = {
  name: string;
  role: TUserRole;
};

const userAtom = atom<TUser>({ name: "Rizki", role: "user" });

const isUserAdminAtom = atom((get) => get(userAtom).role === "admin");

The example above shows how to create a derived value from an existing atom. It’s very similar to what we wrote before with React.useState.

In Jotai, these are called recipes. We can compose atoms into other atoms, or create new atoms based on existing ones to produce a new value.

Jotai lets you create these derived values declaratively, without explicitly managing complex state relationships.

Recipes

Recipes are just derived atoms, and derived atoms usually follow one of two principles.

A read-only atom takes another atom as input and transforms its value through a calculation performed by a function. The result is a derived value that reflects a computation or modification of the original atom’s value.

A write-only atom accepts new values through a write mechanism, like a callback. Those values can then trigger specific actions or update other atoms from inside your write-only atom.

Now that you know these terms, let’s create some recipes.

0. Compose Recipes

Composing atoms is a way to transform an atom through multiple function calls. The returned value should be predictable and have no side effects.

@/atoms/compose.ts

import type { Atom } from "jotai";

type ComposeAtomFn<T> = (value: Atom<T>) => Atom<T>;

export function composeAtom<T>(...fns: Array<ComposeAtomFn<T>>) {
  return (value: Atom<T>) => {
    return fns.reduce((currentValue, fun) => fun(currentValue), value);
  };
}

1. Boolean Recipes

Switch! Who doesn’t need a boolean? Everyone does, so let’s create some boolean recipes.

We usually manage a boolean with React.useState to handle an open or closed state, like a sidebar or dialog. But what if we bake it with Jotai instead?

@/atoms/boolean.ts

import { atom, type PrimitiveAtom } from "jotai";

export function openAtom(boolAtom: PrimitiveAtom<boolean>) {
  return atom(null, (_, set) => set(boolAtom, true));
}

export function closeAtom(boolAtom: PrimitiveAtom<boolean>) {
  return atom(null, (_, set) => set(boolAtom, false));
}

export function toggleAtom(boolAtom: PrimitiveAtom<boolean>) {
  return atom(null, (get, set) => set(boolAtom, !get(boolAtom)));
}

With that snippet, we can create a handler for each action on our open/closed state.

@/atoms/sidebar.ts

import { openAtom, closeAtom, toggleAtom } from "@/atoms/boolean";

import { atom } from "jotai";

export const sidebarAtom = atom(false);
export const openSidebarAtom = openAtom(sidebarAtom);
export const closeSidebarAtom = closeAtom(sidebarAtom);
export const toggleSidebarAtom = toggleAtom(sidebarAtom);

Let me explain that code: openSidebarAtom, closeSidebarAtom, and toggleSidebarAtom are write-only atoms. In other words, they’re handlers that mutate our sidebarAtom, Jotai style.

Now let’s see how we can use those fancy atoms.

Our sidebar component observes the sidebarAtom value and renders the UI accordingly. It can also close itself.

@/components/sidebar.tsx

import { sidebarAtom, closeSidebarAtom } from "@/atoms/sidebar";

import { Drawer } from "@mantine/core";
import { useAtomValue, useSetAtom } from "jotai";

export function Sidebar() {
  const isSidebarOpen = useAtomValue(sidebarAtom);
  const closeSidebar = useSetAtom(closeSidebarAtom);

  return <Drawer opened={isSidebarOpen} onClose={closeSidebar} />;
}

Then there’s the button that opens the sidebar. It can live in our <Header /> component.

@/components/header.tsx

import { openSidebarAtom } from "@/atoms/sidebar";

import { useSetAtom } from "jotai";
import { MenuIcon } from "lucide-react";

export function Header() {
  const openSidebar = useSetAtom(openSidebarAtom);
  return (
    <header>
      {/* other content might be here */}
      <button onClick={openSidebar}>
        <MenuIcon />
      </button>
    </header>
  );
}

2. String Recipes

A string recipe is usually a read-only atom: you take a string atom and transform it into a new string value.

This is very helpful whenever I work with strings, for example, lowercasing a string, encoding strings to URI components, opens in a new tab, and so on.

@/atoms/string.ts

import { atom, type Atom } from "jotai";

export function upperCaseAtom(stringAtom: Atom<string>) {
  return atom((get) => get(stringAtom).toUpperCase());
}

export function lowerCaseAtom(stringAtom: Atom<string>) {
  return atom((get) => get(stringAtom).toLowerCase());
}

export function noSpaceStringAtom(stringAtom: Atom<string>) {
  return atom((get) => get(stringAtom).replace(/\s+/g, ""));
}

Here’s how to encode a string as a URI component:

@/atoms/string.ts

import { atom, type Atom } from "jotai";

export function encodeURIAtom(stringAtom: Atom<string>) {
  return atom((get) => encodeURIComponent(get(stringAtom)));
}

3. Number Recipes

Manipulating numbers with atoms is easy. Let’s start with basic math.

@/atoms/number.ts

import { atom, type PrimitiveAtom } from "jotai";

export function addAtom(numberAtom: PrimitiveAtom<number>) {
  return atom((get) => get(numberAtom) + 1);
}
export function multiplyAtom(numberAtom: PrimitiveAtom<number>) {
  return atom((get) => get(numberAtom) * 2);
}
export function divideAtom(numberAtom: PrimitiveAtom<number>) {
  return atom((get) => get(numberAtom) / 2);
}
export function minusAtom(numberAtom: PrimitiveAtom<number>) {
  return atom((get) => get(numberAtom) - 1);
}

The cool thing is that I can also turn a number into a well-formatted value, like a currency.

@/atoms/number.ts

import { atom, type PrimitiveAtom } from "jotai";

export function formatNumberAtom(numberAtom: PrimitiveAtom<number>) {
  return atom((get) => {
    const fmt = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
    });
    return fmt.format(get(numberAtom));
  });
}

4. Array Recipes

Let’s start with a basic array recipe. We have an array of objects, and we want to filter it based on user input.

@/atoms/array.ts

import { composeAtom } from "@/atoms/compose";
import { lowerCaseAtom, noSpaceStringAtom } from "@/atoms/string";

import { atom } from "jotai";

type Food = { name: string };

export const inputAtom = atom("");
export const clearInputAtom = composeAtom(
  lowerCaseAtom,
  noSpaceStringAtom
)(inputAtom);
export const foodsAtom = atom<Array<Food>>([]);
export const filteredFoodsAtom = atom((get) => {
  const search = get(clearInputAtom);
  const foods = get(foodsAtom);

  if (search === "") return foods;
  return foods.filter((food) => {
    return food.name
      .toLowerCase()
      .replace(/\s+/g, "")
      .includes(search.toLowerCase().replace(/\s+/g, ""));
  });
});

Here, filteredFoodsAtom filters foodsAtom based on the inputAtom value the user typed.

Wait, I can actually create a pure function to clean up the string.

@/utils/clearString.ts

export const clearString = (s: string) => s.toLowerCase().replace(/\s+/g, "");

Now, let’s refactor our filteredFoodsAtom.

@/atoms.ts

import { clearString } from "@/utils/clearString";

import { atom } from "jotai";

type Food = { name: string };

export const inputAtom = atom("");
export const foodsAtom = atom<Array<Food>>([]);
export const filteredFoodsAtom = atom((get) => {
  const search = get(inputAtom);
  const foods = get(foodsAtom);

  if (search === "") return foods;
  return foods.filter((food) => {
    const clearSearch = clearString(search);
    const clearName = clearString(food.name);
    return clearName.includes(clearSearch);
  });
});

Another example is sorting an array. Array recipes in Jotai are just a regular declarative way to transform your data.

Sorting an array atom is easy. Just like in the previous example, we derive a new atom that returns the sorted array.

@/atoms.ts

import { compareDesc } from "date-fns";
import { atom } from "jotai";

type Post = { title: string; publishedAt: Date };

export const postAtoms = atom<Array<Post>>([]);

export const latestPostAtoms = atom((get) => {
  const posts = get(postAtoms);

  return posts.toSorted((a, b) => compareDesc(a.publishedAt, b.publishedAt));
});

Well, that’s all I can think of. I’ve probably used more recipes than these, but I can’t remember them right now. Maybe in another post. Thanks for reading!

Enjoyed this note?