| description | Use when working with Jotai X stores (createAtomStore), accessing state in components or callbacks, persisting state to cookies or localStorage |
| name | jotai-x |
| metadata | {"skiller":{"source":".agents/rules/jotai-x.mdc"}} |
Jotai X Consumer Patterns
Core Principle
Minimize subscriptions, maximize performance. Use hooks (useValue) only when you need reactivity. Use store methods (store.get, store.set) everywhere else.
When to Use
- Creating or consuming Jotai X stores (
createAtomStore)
- Accessing state in components, callbacks, or event handlers
- Choosing between
useAppValue vs store.get, useAppSet vs store.set
- Optimizing component re-renders and subscriptions
The Golden Rule
const count = useAppValue("count");
const handleClick = () => {
const count = store.get("count");
console.log(count);
};
const handleIncrement = () => {
store.set("count", (prev) => prev + 1);
};
Decision Guide
Reading State:
- Need component to re-render when value changes? →
useAppValue('key')
- Just need current value once? →
store.get('key')
- In callback/event handler? →
store.get('key')
- In useEffect? → Usually
store.get('key') unless you need dependency
Writing State:
- All cases →
store.set('key', value)
Getting both read + write:
- Never use
useAppState or useAppSet - use combinations above
Quick Reference
| Scenario | Use | Don't Use |
|---|
| Component needs reactivity | useAppValue('key') | store.get('key') |
| Callback reads once | store.get('key') | useAppValue('key') |
| Event handler reads | store.get('key') | useAppValue('key') |
| Any write operation | store.set('key', val) | useAppSet('key') |
| Need both read + write | useAppValue + store.set | useAppState |
API Reference
Creating a Store
import { createAtomStore } from "jotai-x";
export const {
useChatStore,
useChatValue,
ChatProvider,
} = createAtomStore(
{
count: 0,
name: "Alice",
items: [] as string[],
},
{
name: "chat",
}
);
Store Instance API
const store = useChatStore();
store.get("count");
store.getCount();
store.set("count", 5);
store.set("count", (c) => c + 1);
store.setCount(5);
const unsub = store.subscribe("count", (value) => {
console.log(value);
});
Hook API (Use Sparingly)
const count = useChatValue("count");
const firstItem = useChatValue(
"items",
{
selector: (items) => items[0],
},
[]
);
const items = useChatValue(
"items",
{
equalityFn: (a, b) => a.length === b.length,
},
[]
);
Complete Example
import { createAtomStore } from "jotai-x";
export const { useChatStore, useChatValue, ChatProvider } = createAtomStore(
{
messages: [] as Message[],
input: "",
status: "idle" as "idle" | "loading",
},
{
name: "chat",
}
);
export const useSendMessage = () => {
const store = useChatStore();
return async () => {
const input = store.get("input");
const messages = store.get("messages");
if (!input.trim()) return;
store.set("status", "loading");
store.set("input", "");
try {
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ input, messages }),
});
const newMessage = await response.json();
store.set("messages", (prev) => [...prev, newMessage]);
store.set("status", "idle");
} catch (error) {
store.set("status", "idle");
console.error(error);
}
};
};
function ChatMessages() {
const messages = useChatValue("messages");
const status = useChatValue("status");
return (
<div>
{messages.map((msg) => (
<Message key={msg.id} {...msg} />
))}
{status === "loading" && <LoadingSpinner />}
</div>
);
}
function ChatInput() {
const store = useChatStore();
const sendMessage = useSendMessage();
const input = useChatValue("input");
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
>
<input
value={input}
onChange={(e) => {
// ✅ Use store.set directly
store.set("input", e.target.value);
}}
/>
<button
type="submit"
onClick={() => {
// ✅ Can read current value without subscribing
if (store.get("status") === "loading") {
return; // Don't submit while loading
}
}}
>
Send
</button>
</form>
);
}
Common Mistakes
❌ Using hooks in callbacks
function Component() {
const count = useChatValue("count");
const handleClick = () => {
console.log(count);
};
}
function Component() {
const store = useChatStore();
const handleClick = () => {
console.log(store.get("count"));
};
}
❌ Using useAppState
const [count, setCount] = useChatState("count");
const count = useChatValue("count");
const store = useChatStore();
❌ Using useAppSet
const setCount = useChatSet("count");
setCount(5);
const store = useChatStore();
store.set("count", 5);
❌ Over-subscribing
function Component() {
const input = useChatValue("input");
const status = useChatValue("status");
const messages = useChatValue("messages");
const error = useChatValue("error");
return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.text}</div>
))}
{status === "loading" && <Spinner />}
</div>
);
}
function Component() {
const messages = useChatValue("messages");
const status = useChatValue("status");
}
Rationalization Table
| Excuse | Reality |
|---|
| "README shows useAppValue, so I should use it" | README shows all features. Use store.get/set for better performance. |
| "Hooks are the React way" | Hooks for reactivity. Store methods for one-time access. Both are React patterns. |
| "store.get/set is more advanced" | Actually simpler - direct access without subscription overhead. |
| "Using hooks everywhere is more consistent" | Consistency at the cost of performance. Each tool for its purpose. |
| "Official docs recommend useAppValue" | Docs show options. Choose based on whether you need subscription. |
| "I need the setter from useAppState" | Use store.set instead - works everywhere, not just in components. |
| "I'll optimize later, hooks for now" | Wrong pattern from start = tech debt. Use store.get/set from beginning. |
| "Getting store adds extra line of code" | One line enables all direct access. More efficient than multiple hooks. |
| "What if I need value multiple times?" | store.get is instant, no overhead. Call it as many times as needed. |
| "Hooks auto-subscribe, don't have to think" | Thinking prevents bugs. Explicit > implicit. Performance > convenience. |
Red Flags - Check Your Code
If you see any of these patterns, reconsider:
useAppState anywhere (almost never needed)
useAppSet anywhere (use store.set)
useAppValue in a callback/event handler (use store.get)
- More than 2-3
useAppValue calls in one component (over-subscribing)
useAppValue but never using the returned value in JSX (just use store.get)
All of these mean: Switch to store.get or store.set.
Performance Impact
Bad pattern (unnecessary subscriptions):
function UserProfile() {
const firstName = useChatValue("firstName");
const lastName = useChatValue("lastName");
const email = useChatValue("email");
const age = useChatValue("age");
return (
<div>
{firstName} {lastName}
</div>
);
}
Good pattern (minimal subscriptions):
function UserProfile() {
const firstName = useChatValue("firstName");
const lastName = useChatValue("lastName");
const store = useChatStore();
const handleSubmit = () => {
const email = store.get("email");
const age = store.get("age");
};
return (
<div>
{firstName} {lastName}
</div>
);
}
Advanced Patterns
Derived Values
export const useChatEmpty = () => {
const status = useChatValue("status");
const messages = useChatValue("messages");
const hasSubmitted = useChatValue("hasSubmitted");
return !hasSubmitted && messages.length === 0 && status === "ready";
};
Switching State
export const useSwitchChat = () => {
const store = useChatStore();
const currentChatId = useChatValue("chatId");
return (chatId: string) => {
if (chatId === currentChatId) return;
store.set("chatId", chatId);
store.set("newChatId", null);
store.set("status", "syncing");
store.get("actions").setMessages([]);
store.set("hasSubmitted", false);
};
};
Complex Operations
export const useRetry = () => {
const store = useChatStore();
const messages = useChatValue("messages");
return async (messageId?: string) => {
const currentIdx = messageId
? messages.findIndex((m) => m.id === messageId)
: messages.findLastIndex((m) => m.role === "assistant");
const chatId = store.get("chatId");
const actions = store.get("actions");
await deleteMessages(chatId, messageId);
const newMessages = messages.slice(0, currentIdx);
actions.setMessages(newMessages);
actions.regenerate();
};
};
Persistent State
Persist store values to cookies or localStorage by using atomWithCookie or atomWithLocalStorage directly in store definition.
When to Use Which
| Storage | Use Case |
|---|
| Cookie | Server-side access needed, SSR hydration, small data |
| LocalStorage | Client-only, larger data, no SSR needed |
atomWithCookie (SSR-friendly)
1. Store definition - Replace primitive with atomWithCookie:
import { atomWithCookie } from "@/lib/utils/atomWithCookie";
export const { useChatStore, useChatValue, ChatProvider } = createAtomStore(
{
isOpen: atomWithCookie("chat_isOpen", true),
},
{ name: "chat" }
);
2. Server-side hydration - Read cookie and pass to Provider:
import { cookies } from "next/headers";
import { getCookieParser } from "@/lib/utils/getCookieParser";
export async function Layout({ children }) {
const cookieStore = await cookies();
const parser = getCookieParser(cookieStore);
const isOpen = parser.boolean("chat_isOpen") ?? true;
return <ChatProvider initialValues={{ isOpen }}>{children}</ChatProvider>;
}
Cookie naming convention: prefix_key (e.g., chat_isOpen, chat_mode)
atomWithLocalStorage (Client-only)
import { atomWithLocalStorage } from "@/lib/utils/atomWithLocalStorage";
export const { useAppStore, useAppValue, AppProvider } = createAtomStore(
{
theme: atomWithLocalStorage("app_theme", "light"),
},
{ name: "app" }
);
No server hydration needed - localStorage is client-only.
Common Mistakes
createAtomStore(
{ isOpen: true },
{
name: "chat",
extend: (atoms) => ({ ...atoms, isOpen: someAtom }),
}
);
createAtomStore({ isOpen: atomWithCookie("chat_isOpen", true) }, { name: "chat" });
<ChatProvider initialValues={{}}> // Cookie value ignored on first render
// ✅ CORRECT - Pass server-read value
<ChatProvider initialValues={{ isOpen }}> // Hydrated from cookie