iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎄

Solving Jotai Atom Clutter with `import * as ns`

に公開

Introduction

Jotai is extremely powerful for state management in React.
However, as applications grow, there is a problem everyone eventually encounters.

"Aren't there too many atoms?"

In this article, I'll summarize the reasons and benefits of leveraging namespace imports.

Background: Following the "Jotai Way" Leads to File Chaos

The fundamental philosophy of Jotai is "Atomic" state management.
Instead of maintaining a giant store, you define the smallest units of state (Atoms) and build the app by combining them.

However, the more seriously you use Jotai, the more atoms like these are mass-produced:

  • Basic Atoms: primitiveAtom
  • Derived Atoms: derivedAtom (Read-only)
  • Write-only Atoms: writeOnlyAtom (Action)

Implementing these straightforwardly and using them in components leads to an explosion in the import section.

// 😫 Traditional imports: it's hard to tell at a glance which atom belongs to what
import {
  customerNameAtom,
  isLoggedInAtom,
  cartTotalAtom,
  cartCountAtom,
  clearCartAtom,
} from "./atoms";
import { useAtom, useSetAtom, useAtomValue } from "jotai";

...
// Inside a component
const customerName = useAtomValue(customerNameAtom);
const isLoggedIn = useAtomValue(isLoggedInAtom);
const cartTotal = useAtomValue(cartTotalAtom);
const cartCount = useAtomValue(cartCountAtom);
const clearCart = useSetAtom(clearCartAtom);
...

Why Not Group Them into "Objects" Like Zustand?

People who have used single-store libraries like Zustand might have this question.

"I wish I could group atoms into a single object and access them like customerAtoms.name."

import { useCustomerStore } from './stores/customerStore';
import { useCartStore } from './stores/cartStore';
// With Zustand, you can write it like this
const customerName = useCustomerStore((state) => state.name);
const isLoggedIn = useCustomerStore((state) => state.isLoggedIn);
const cartTotal = useCartStore((state) => state.total);
const cartCount = useCartStore((state) => state.count);
const clearCart = useCartStore((state) => state.clear);

However, in Jotai, grouping atoms into an object is a bad move (or even impossible).

Reasons are as follows:

Jotai Atoms Rely on "Referential Identity"

If you dynamically objectify atoms inside a component or function, the reference changes, causing infinite re-renders.

Manual Objectification at the Top Level Degrades DX

  • It makes it harder for code splitting to exclude unused atoms.

This results in undermining the strengths of Jotai.

Solution: Use import * as (namespace import)

The approach I recommend is to treat the file (module) itself as an object.

Group related atoms into a single file and import them as a namespace on the consumer side as follows.

1. Atom Definition Side

atoms/customerAtoms.ts
import { atom } from 'jotai';

interface Customer {
  id: string;
  name: string;
  email: string;
}

export const data = atom<Customer | null>(null);
export const name = atom((get) => get(data)?.name ?? '');
export const email = atom((get) => get(data)?.email ?? '');
export const isLoggedIn = atom((get) => get(data) !== null);
atoms/cartAtoms.ts
import { atom } from 'jotai';

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

export const items = atom<CartItem[]>([]);
export const total = atom((get) => {
  const cartItems = get(items);
  return cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
});
export const count = atom((get) => {
  const cartItems = get(items);
  return cartItems.reduce((sum, item) => sum + item.quantity, 0);
});
export const clear = atom(null, (_get, set) => set(items, []));

2. Consumer Side (Component)

components/HeaderCartSummary.tsx
import { useAtomValue, useSetAtom } from 'jotai';
// ✨ namespace import
import * as customerAtoms from '@/atoms/customerAtoms';
import * as cartAtoms from '@/atoms/cartAtoms';

export const HeaderCartSummary = () => {
  const customerName = useAtomValue(customerAtoms.name);
  const isLoggedIn = useAtomValue(customerAtoms.isLoggedIn);
  const cartTotal = useAtomValue(cartAtoms.total);
  const cartCount = useAtomValue(cartAtoms.count);
  const clearCart = useSetAtom(cartAtoms.clear);

  if (!isLoggedIn) {
    return <p>ログインしてカートを表示</p>;
  }

  return (
    <div>
      <p>{customerName}さんのカート</p>
      <p>商品点数: {cartCount}点</p>
      <p>合計: {cartTotal.toLocaleString()}円</p>
      <button onClick={clearCart}>カートを空にする</button>
    </div>
  );
};

Benefits of This Pattern

1. Origin (Domain) is Explicitly Shown in the Code

You can determine which domain an atom belongs to without having to check the import statements.

For example, if you write it like this:

useAtom(cartAtoms.items);

It is clear at a glance that it is an atom from the cartAtoms domain.
It feels similar to using Zustand's useCartStore(state => state.items).

2. No Need to Append Atom to Variable Names (Concise Naming)

before:

  • customerNameAtom
  • isLoggedInAtom
  • cartTotalAtom
  • cartCountAtom
  • clearCartAtom

after:

  • customerAtoms.name
  • customerAtoms.isLoggedIn
  • cartAtoms.total
  • cartAtoms.count
  • cartAtoms.clear

Writing useAtom(customerAtoms.name) already makes it clear that "this is an atom."


To use namespace imports, you need to group related atoms into a single file.

This naturally creates a motivation:

"To make things easier to read via namespaces, let's organize these atoms by functionality."

As a result, your directory structure stays clean.

4. Namespaces can be nested

As functionality becomes more complex, you can nest namespaces to organize them even more finely.

Directory structure image
atoms/
└── carts
    ├── index.ts ## Use this as an entry point to aggregate all atoms
    ├── items.ts
    └── prices.ts

Code examples

atoms/carts/items.ts
import { atom } from 'jotai';

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

export const list = atom<CartItem[]>([]);
export const count = atom((get) => {
  const items = get(list);
  return items.reduce((sum, item) => sum + item.quantity, 0);
});
atoms/carts/prices.ts
import { atom } from 'jotai';

import * as itemAtoms from './items';

export const total = atom((get) => {
  const items = get(itemAtoms.list);
  return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
});
export const tax = atom((get) => Math.floor(get(total) * 0.1));
export const totalWithTax = atom((get) => get(total) + get(tax));
atoms/carts/index.ts
export * as items from './items';
export * as prices from './prices';
components/cart-summary.tsx
import { useAtomValue } from 'jotai';
import * as cartAtoms from '@/atoms/carts';
// ✅️ Can be used like cartAtoms.items.list, cartAtoms.prices.total!
const itemCount = useAtomValue(cartAtoms.items.count);
const totalWithTax = useAtomValue(cartAtoms.prices.totalWithTax);

Summary

An increasing number of atoms is a specification of Jotai, but messy code is not.

  1. Don't force atoms into objects.
  2. Group them by file (module).
  3. Call them using import * as ns.

Just by doing this, the development experience with Jotai will improve significantly.

If you are a Jotai user who misses the cohesiveness of Zustand, please give this pattern a try.

GitHubで編集を提案

Discussion