frontend

Module-scoped Jotai atoms: how we manage state across 30+ pages in Plato

Why module-scoped Jotai atoms worked better for Plato than one large global store, and how the boundaries held up as the app grew to 30+ pages.

Plato is the internal CRM at PickYourTrail. It’s a Next.js application with 30-plus pages across modules for chat, account ownership, finance, visa, inbound leads, itinerary planning, sales tracking, and a bunch of others. Every module has its own data, its own UI state, its own workflows.

When we started building it, I made a choice about state management that I’ve been pretty happy with: Jotai with module-scoped atom files. Not a single global store, not Redux, not Zustand (though Zustand was a close call). A flat atom-per-module model where each domain owns its own state slice.

This is a walkthrough of how that works in practice, where it’s held up, and where it doesn’t automatically solve things for you.

The global store problem

The thing I distrust most in large frontends is the giant global store that becomes the default answer for every piece of state.

It looks organized in the beginning. You have a store/ directory, maybe a few slices or reducers, and it all feels tidy. Then the app grows. Features get added. The “obvious” place to put new state is wherever the existing state is, and before long you have unrelated things sharing a home. A component in the finance module imports from the chat slice because the user’s current selected chat affects whether a certain button is enabled. A visa form depends on state that was added for the sales dashboard.

You start needing to think about the whole store before you can safely change any part of it. In an app the size of Plato, that’s a real cognitive burden.

That’s the problem I was trying to avoid from the start.

Why Jotai

I evaluated Redux, Zustand, and Jotai before settling on Jotai. The short version of why:

Redux has power, but the ceremony is real. Actions, reducers, selectors, the whole dispatch-subscribe model, it’s a lot of structure to maintain for what is at the end of the day UI state. The Redux Toolkit reduced the boilerplate, but I still didn’t want every state decision to be a negotiation through a central architecture.

Zustand is much lighter and I seriously considered it. The API is clean. The main reason I didn’t go with Zustand is that I wanted something that composed more naturally at the atom level, where you define a unit of state and then build derived state from it declaratively. Jotai’s model felt closer to the mental model I had for how Plato’s state actually worked.

Jotai’s atomic model lets you define the smallest possible unit of state and then compose. An atom is just a piece of state. A derived atom reads from other atoms. The React integration is minimal, useAtom replaces useState. The primitives are small.

The module-scoped atom pattern

The structure we landed on: one atom file per major module, living in store/.

store/
  atoms.tsx         , global/shared atoms (authUser, etc.)
  aoAtom.tsx        , account ownership module
  chatAtom.tsx      , chat module
  financeAtom.tsx   , finance module
  visaAtom.tsx      , visa module
  inTrailAtom.tsx   , itinerary/trail module
  flowAtom.tsx      , workflow module
  salesAtom.tsx     , sales tracking
  beAtom.tsx        , back-end / BE concerns
  selectors.tsx     , cross-module derived state
  selectorsWithIndexedDb.tsx

Each module owns its own atoms. The aoAtom.tsx file contains everything account-ownership-related. If you’re working in that module, that’s the only atom file you need to think about.

Here’s what aoAtom.tsx actually looks like:

import { atom } from 'jotai';
import { AoMetrics, fetchAoMetrics } from '@apis/fetchAoMetrics';
import { AoTask } from '@apis/fetchAoTasks';
import { authUserAtom } from './atoms';

export const aoSummaryMetricsFilterAtom = atom({});
export const aoSummaryMetricsRefreshAtom = atom(0);

// Async derived atom, reads from auth + filters, fetches data
export const aoSummaryMetricsAtom = atom<Promise<AoMetrics | null>>(
  async (get) => {
    const user = get(authUserAtom);
    const filters = get(aoSummaryMetricsFilterAtom);
    get(aoSummaryMetricsRefreshAtom); // tracked as a dependency for manual refresh

    if (user) {
      try {
        const { data } = await fetchAoMetrics({
          authToken: user.accessToken,
          filters,
        }, fetchWrapper);
        return data;
      } catch {
        return null;
      }
    }
    return null;
  }
);

aoSummaryMetricsAtom.debugLabel = 'aoSummary';

export const aoSelectedTaskAtom = atom<AoTask | null>(null);
aoSelectedTaskAtom.debugLabel = 'aoSelectedTaskAtom';

The aoSummaryMetricsAtom is an async derived atom, it reads from authUserAtom (in the shared atoms file) and from aoSummaryMetricsFilterAtom (owned by the AO module). When either changes, the metrics refresh. The aoSummaryMetricsRefreshAtom is a counter that you increment manually to force a refresh, get() on it registers a dependency even though we don’t use the value.

The chatAtom.tsx file has a different character, it uses atomWithStorage for persisted UI state:

import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { IChat } from '@apis/fetchChats';

export const currentChatAtom = atom<IChat | null>(null);
currentChatAtom.debugLabel = 'currentChat';

export const chatProfileToogleAtom = atomWithStorage<boolean>(
  'chatProfileToogle',
  false,
);

export type ChatProfileTab = 'chatDetails' | 'quickAnswers' | 'media' | 'otherChats' | 'agent' | 'members';

export const chatProfileTabAtom = atomWithStorage<ChatProfileTab>(
  'chatProfileTab',
  'chatDetails'
);

chatProfileToogleAtom and chatProfileTabAtom use atomWithStorage, they persist to localStorage, so if you open a chat profile panel and then navigate away, the panel remembers its state when you come back. That’s one line. In a Redux setup, persisting a specific slice to localStorage involves middleware.

Shared atoms and the atoms.tsx file

Global atoms, things that really are app-wide, live in atoms.tsx. The main one is authUserAtom, which uses a custom atomWithCompare to prevent unnecessary re-renders when the user object is updated but nothing meaningful changed:

import { atomWithReset } from 'jotai/utils';
import atomWithCompare from '@utils/atomWithCompare';
import shallowEqual from '../utils/shallowEqual';

export type AuthUser = Session & {
  accessToken?: string;
};

export const authUserAtom = atomWithCompare<AuthUser | null>(
  null,
  shallowEqual,
);
authUserAtom.debugLabel = 'authUser';

atomWithCompare is a custom utility that only notifies subscribers when the value has actually changed (via a shallow equality check). Without this, every session refresh would re-render every component subscribed to authUserAtom, including async derived atoms in every module that depend on it.

Separating server state from UI state

One thing that made the architecture cleaner: keeping a hard line between server state and client state.

React Query handles all API-backed data fetching. When you need a list of trails, a customer record, or booking history, that goes through React Query. It handles caching, background refresh, loading states, and error handling.

Jotai handles UI state, the currently selected chat, filter values, panel open/closed state, cross-page workflows. These are things that React Query isn’t designed for and that don’t need server synchronization.

Once you hold that separation cleanly, the Jotai store becomes much smaller and clearer. You’re not managing request state, deduplication, or cache invalidation inside Jotai. Each tool does its job.

The failure mode I’ve seen in other frontends: someone puts API response data in the global store because “it’s global state.” Then they implement their own loading/error states, their own cache, their own invalidation logic. You end up with a homegrown React Query that’s worse than React Query in every way.

Cross-module state: selectors

Module isolation is useful, but sometimes you genuinely need a view that aggregates across modules. A page that shows account-ownership data alongside finance data alongside trail data, that’s a real use case.

That’s what store/selectors.tsx and store/selectorsWithIndexedDb.tsx are for. Selectors are derived atoms that read from multiple module atom files and compose a higher-level view.

The key constraint: selectors are read-only aggregations. They don’t store state, they derive it. The module atoms remain the source of truth. The selector is a computed view for a specific page or component that needs a cross-module perspective.

This is the part that Jotai handles particularly well. Because derived atoms are first-class (you just call get() on other atoms inside the derivation function), composing across modules is the same API as composing within a module. No special machinery.

What breaks when the module boundary is wrong

Jotai doesn’t automatically give you good boundaries. It just makes bad boundaries visible faster.

If an atom in visaAtom.tsx starts getting imported directly by components in the finance module, that’s a sign the boundary is wrong. Either the shared concept should move to atoms.tsx, or you need a selector that composes both, or the modules need to be reconsidered.

In a giant global Redux store, this coupling often hides. Everything is already in one place, so it’s less visible when unrelated modules reach into each other. With module-scoped files, the import statement tells the truth immediately.

That’s actually one of the things I like most about this approach. The architecture tells you when a boundary is getting fuzzy. A file with too many cross-module imports is a code smell you can grep for.

What I’d do differently

The atom naming convention got inconsistent over time. Some atoms have debugLabel set (which makes React DevTools much more useful), others don’t. I should have made that mandatory from the start, it’s one line and it makes debugging much easier when you can see aoSummary in the DevTools instead of Atom1.

I also accumulated more atoms in atoms.tsx than should be there. It became the default for anything that felt “shared”, but “shared” should mean genuinely app-wide, like auth state, not “used in two modules.” Some of those should have been pulled into selectors or into their own shared-concern files.

The atomWithCompare custom utility has no tests. It’s a small utility but it’s in the critical path for the auth atom that everything depends on. I keep meaning to add tests for it.

Thirty-plus pages in

Looking at the store directory now, aoAtom, chatAtom, financeAtom, visaAtom, flowAtom, salesAtom, inTrailAtom, beAtom, cxConciergeAtom, maggiAtom, this has held up reasonably well. Engineers working in a module know where their state lives. New module additions follow the same pattern. The selector files are messier than I’d like, but they’re contained.

The core insight that drove the decision holds: in a large internal app, the problem isn’t that state is hard. It’s that state without domain boundaries becomes invisible coupling. Module-scoped atoms keep the coupling explicit.

That’s been worth the small overhead of a more opinionated file structure.