Jotai X Consumer Patterns
SkillFiles & storageUse when working with Jotai X stores (createAtomStore), accessing state in components or callbacks, persisting state to cookies or localStorage
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Jotai X Consumer Patterns skill
What this skill tells your AI
The instructions your AI receives, as published by udecode/kitcn in .agents/skills/jotai-x/SKILL.md and read by ahel’s review.
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
useAppValuevsstore.get,useAppSetvsstore.set - Optimizing component re-renders and subscriptions
The Golden Rule
// ✅ Subscribe with hooks ONLY when component needs to re-render on change
const count = useAppValue("count"); // Component re-renders when count changes
// ✅ Read without subscribing in callbacks/handlers
const handleClick = () => {
const count = store.get("count"); // Just reads current value, no subscription
console.log(count);
};
// ✅ Write with store.set in callbacks/handlers
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
useAppStateoruseAppSet- 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, // Get store instance
useChatValue, // Subscribe to value (use sparingly!)
ChatProvider, // Provider component
} = createAtomStore(
{
count: 0,
name: "Alice",
items: [] as string[],
},
{
name: "chat", // Prefix for all hooks
}
);
Store Instance API
const store = useChatStore();
// Read without subscribing
store.get("count"); // Get single value
store.getCount(); // Alternative syntax
// Write
store.set("count", 5); // Set value
store.set("count", (c) => c + 1); // Update with function
store.setCount(5); // Alternative syntax
// Subscribe (rarely needed - prefer useAppValue)
const unsub = store.subscribe("count", (value) => {
console.log(value);
});
Hook API (Use Sparingly)
// Subscribe to value (component re-renders on change)
const count = useChatValue("count");
// With selector
const firstItem = useChatValue(
"items",
{
selector: (items) => items[0],
},
[]
);
// With equality function
const items = useChatValue(
"items",
{
equalityFn: (a, b) => a.length === b.length,
},
[]
);
Complete Example
import { createAtomStore } from "jotai-x";
// 1. Create store
export const { useChatStore, useChatValue, ChatProvider } = createAtomStore(
{
messages: [] as Message[],
input: "",
status: "idle" as "idle" | "loading",
},
{
name: "chat",
}
);
// 2. Custom hook for complex operations
export const useSendMessage = () => {
const store = useChatStore();
return async () => {
// ✅ Use store.get to read without subscribing
const input = store.get("input");
const messages = store.get("messages");
if (!input.trim()) return;
// ✅ Use store.set to write
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();
// ✅ Functional update
store.set("messages", (prev) => [...prev, newMessage]);
store.set("status", "idle");
} catch (error) {
store.set("status", "idle");
console.error(error);
}
};
};
// 3. Component with minimal subscriptions
function ChatMessages() {
// ✅ Subscribe ONLY to what component needs to render
const messages = useChatValue("messages");
const status = useChatValue("status");
return (
<div>
{messages.map((msg) => (
<Message key={msg.id} {...msg} />
))}
{status === "loading" && <LoadingSpinner />}
</div>
);
}
// 4. Component with event handlers
function ChatInput() {
const store = useChatStore();
const sendMessage = useSendMessage();
// ✅ Subscribe to input for controlled input
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
// ❌ WRONG - Creates subscription for no reason
function Component() {
const count = useChatValue("count"); // Subscribes!
const handleClick = () => {
console.log(count); // Uses stale closure
};
}
// ✅ CORRECT - Read when needed
function Component() {
const store = useChatStore();
const handleClick = () => {
console.log(store.get("count")); // Always current
};
}
❌ Using useAppState
// ❌ WRONG - Over-subscribing
const [count, setCount] = useChatState("count");
// ✅ CORRECT - Subscribe only if component needs reactivity
const count = useChatValue("count");
const store = useChatStore();
// Then use store.set('count', ...) to write
❌ Using useAppSet
// ❌ WRONG - Unnecessary hook call
const setCount = useChatSet("count");
setCount(5);
// ✅ CORRECT - Direct access
const store = useChatStore();
store.set("count", 5);
❌ Over-subscribing
// ❌ WRONG - Component subscribes to everything
function Component() {
const input = useChatValue("input");
const status = useChatValue("status");
const messages = useChatValue("messages");
const error = useChatValue("error");
// But only renders messages and status
return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.text}</div>
))}
{status === "loading" && <Spinner />}
</div>
);
}
// ✅ CORRECT - Subscribe only to what you render
function Component() {
const messages = useChatValue("messages");
const status = useChatValue("status");
// Access input/error with store.get when needed
}
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:
useAppStateanywhere (almost never needed)useAppSetanywhere (usestore.set)useAppValuein a callback/event handler (usestore.get)- More than 2-3
useAppValuecalls in one component (over-subscribing) useAppValuebut never using the returned value in JSX (just usestore.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");
// Component re-renders on ANY change to firstName, lastName, email, OR age
return (
<div>
{firstName} {lastName}
</div>
);
}
// Subscribes to 4 atoms, only uses 2 in render
Good pattern (minimal subscriptions):
function UserProfile() {
// Only subscribe to what we render
const firstName = useChatValue("firstName");
const lastName = useChatValue("lastName");
// Access others with store.get when needed in callbacks
const store = useChatStore();
const handleSubmit = () => {
const email = store.get("email");
const age = store.get("age");
// ...
};
return (
<div>
{firstName} {lastName}
</div>
);
}
// Subscribes to 2 atoms, exactly what we render
Advanced Patterns
Derived Values
// Custom hook with derived state
export const useChatEmpty = () => {
// ✅ Subscribe only to values used in computation
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;
// ✅ Multiple writes with store.set
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();
// ✅ Subscribe only if we need reactivity for computation
const messages = useChatValue("messages");
return async (messageId?: string) => {
const currentIdx = messageId
? messages.findIndex((m) => m.id === messageId)
: messages.findLastIndex((m) => m.role === "assistant");
// ✅ Read values with store.get - no subscription needed
const chatId = store.get("chatId");
const actions = store.get("actions");
// Perform async operation...
await deleteMessages(chatId, messageId);
// ✅ Write with store.set
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), // ✅ Direct assignment
// ... other state
},
{ name: "chat" }
);
2. Server-side hydration - Read cookie and pass to Provider:
// Layout.tsx (Server Component)
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"),
// ... other state
},
{ name: "app" }
);
No server hydration needed - localStorage is client-only.
Common Mistakes
// ❌ WRONG - Don't use extend option for persistence
createAtomStore(
{ isOpen: true },
{
name: "chat",
extend: (atoms) => ({ ...atoms, isOpen: someAtom }), // Unnecessary complexity
}
);
// ✅ CORRECT - Direct assignment
createAtomStore({ isOpen: atomWithCookie("chat_isOpen", true) }, { name: "chat" });
// ❌ WRONG - Forgetting server hydration with cookies
<ChatProvider initialValues={{}}> // Cookie value ignored on first render
// ✅ CORRECT - Pass server-read value
<ChatProvider initialValues={{ isOpen }}> // Hydrated from cookie
Signals
- GitHub stars
- 447
- Forks
- 34
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
jotai-x- Source
- github.com/udecode/kitcn