| name | add-scoped-store |
| description | Creates a per-entity scoped Zustand store using createScopedStore(). Use when you need independent state instances keyed by ID — per-interaction tabs, per-conversation messages, per-workspace scratchpads, etc. |
| metadata | {"author":"reactive","version":"1.0"} |
Add a Scoped Store
createScopedStore() creates a factory that manages a Map<string, StoreApi<T>> — one independent Zustand store per scope key. Each scope is lazily created on first access and can be cleaned up individually.
Use scoped stores when:
- You need per-entity state (per-interaction, per-conversation, per-tab, per-workspace).
- Multiple instances of the same state shape exist simultaneously.
- Entity lifecycle doesn't match component lifecycle (state survives tab switches, unmounts).
Do NOT use scoped stores for:
- App-wide singleton state — use a regular Zustand store in
AppDependencies.
- Server data — use React Query.
Step 1: Define the state type
export interface TabState {
tabs: Tab[];
activeTabId: string;
addTab: (tab: Tab) => void;
removeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
}
export interface Tab {
id: string;
label: string;
type: "directory" | "detail" | "settings";
}
Step 2: Create the scoped store
import { createScopedStore } from "@tanstack-react-modules/core";
import type { TabState, Tab } from "@example/app-shared";
function createDirectoryTab(): Tab {
return { id: "directory", label: "Directory", type: "directory" };
}
export const interactionTabs = createScopedStore<TabState>(() => ({
tabs: [createDirectoryTab()],
activeTabId: "directory",
addTab: (tab) => {
},
removeTab: (tabId) => {},
setActiveTab: (tabId) => {},
}));
For stores with actions, use the imperative API to define them properly:
export const interactionTabs = createScopedStore<TabState>(() => {
return {
tabs: [createDirectoryTab()],
activeTabId: "directory",
addTab: () => {},
removeTab: () => {},
setActiveTab: () => {},
};
});
export function getInteractionTabs(interactionId: string) {
const store = interactionTabs.getOrCreate(interactionId);
store.setState({
addTab: (tab: Tab) => store.setState((s) => ({ tabs: [...s.tabs, tab] })),
removeTab: (tabId: string) =>
store.setState((s) => ({
tabs: s.tabs.filter((t) => t.id !== tabId),
activeTabId: s.activeTabId === tabId ? (s.tabs[0]?.id ?? "") : s.activeTabId,
})),
setActiveTab: (tabId: string) => store.setState({ activeTabId: tabId }),
});
return store;
}
Step 3: Use in React components
import { interactionTabs } from "@example/app-shared";
function InteractionWorkspace({ interactionId }: { interactionId: string }) {
const { tabs, activeTabId } = interactionTabs.useScoped(interactionId);
const tabs = interactionTabs.useScoped(interactionId, (s) => s.tabs);
return (
<div>
{tabs.map((tab) => (
<button
key={tab.id}
data-active={tab.id === activeTabId}
onClick={() => {
const store = interactionTabs.getOrCreate(interactionId);
store.setState({ activeTabId: tab.id });
}}
>
{tab.label}
</button>
))}
</div>
);
}
Step 4: Imperative access (outside React)
const store = interactionTabs.getOrCreate("interaction-123");
const state = store.getState();
store.setState({ activeTabId: "settings" });
const unsubscribe = store.subscribe((state) => {
console.log("tabs changed:", state.tabs);
});
interactionTabs.has("interaction-123");
interactionTabs.remove("interaction-123");
interactionTabs.clear();
Step 5: Cleanup
Scoped stores are not garbage-collected automatically. Remove scopes when the entity they belong to is destroyed:
interactionTabs.remove(interactionId);
interactionTabs.clear();
Failing to clean up leaks memory proportional to the number of unique scope IDs created over the session lifetime.
ScopedStore API reference
| Method / Hook | Description |
|---|
getOrCreate(scopeId) | Returns existing store or creates one with the initializer |
has(scopeId) | Returns true if the scope exists |
remove(scopeId) | Deletes the scope's store. No-op if it doesn't exist |
clear() | Removes all scoped stores |
useScoped(scopeId) | React hook — subscribe to full state of a scope |
useScoped(scopeId, sel) | React hook — subscribe with selector, re-renders only on change |
Rules
- The initializer function is called once per scope, on first
getOrCreate or useScoped call.
useScoped creates the scope if it doesn't exist — safe to call without prior getOrCreate.
- Always call
remove() or clear() when entities are destroyed. Scoped stores are not garbage-collected.
- Scoped stores are independent of
AppDependencies — they are not registered in the registry. They're created and owned by the code that needs per-entity state.
- Use selectors with
useScoped(id, selector) to minimize re-renders, same as with regular zustand stores.
- The store instances are standard zustand
StoreApi objects — all zustand patterns work (middleware, subscribe, getState, setState).