Scoped Stores
Scoped stores are child stores that inherit all state from their parent but can override values independently. Think of them as a "fork" of state — reads fall through to the parent, but writes stay local.
This is useful for modals, multi-step forms, drag previews, multi-tenant UIs, or any scenario where you need isolated copies of shared state.
How it works
import { atom, store } from "valdres"
const nameAtom = atom("Alice")
const rootStore = store()
// Create a scoped child store
const childStore = rootStore.scope("child-1")
Reading: inherits from parent
rootStore.set(nameAtom, "Alice")
childStore.get(nameAtom) // "Alice" — falls through to parent
Writing: stays local
childStore.set(nameAtom, "Bob")
childStore.get(nameAtom) // "Bob" — shadowed in child
rootStore.get(nameAtom) // "Alice" — parent unchanged
Parent updates don't overwrite shadows
Once a scope shadows an atom, parent updates to that atom no longer affect the scope:
rootStore.set(nameAtom, "Charlie")
rootStore.get(nameAtom) // "Charlie"
childStore.get(nameAtom) // "Bob" — still the scoped value
But atoms that the scope hasn't shadowed continue to flow through:
const ageAtom = atom(30)
rootStore.set(ageAtom, 31)
childStore.get(ageAtom) // 31 — still reading from parent
Selectors in scopes
Selectors automatically evaluate against the scope's view of state:
import { atom, selector, store } from "valdres"
const priceAtom = atom(100)
const taxAtom = atom(0.2)
const totalSelector = selector(get => get(priceAtom) * (1 + get(taxAtom)))
const root = store()
const child = root.scope("preview")
root.get(totalSelector) // 120
child.set(taxAtom, 0.25)
child.get(totalSelector) // 125 — uses child's tax, parent's price
root.get(totalSelector) // 120 — unchanged
Families in scopes
Atom families work across scopes. A child scope inherits the parent's family members but can add its own:
import { atomFamily, store } from "valdres"
const todoAtom = atomFamily()
const root = store()
const child = root.scope("draft")
root.set(todoAtom("a"), { title: "Buy milk", done: false })
child.get(todoAtom("a")) // { title: "Buy milk", done: false } — inherited
// Add a new item only in the child scope
child.set(todoAtom("b"), { title: "Draft todo", done: false })
root.get(todoAtom) // ["a"]
child.get(todoAtom) // ["a", "b"] — child sees both
Subscriptions
Subscriptions within a scope react to changes in that scope's view of state:
const root = store()
const child = root.scope("child")
const countAtom = atom(0)
child.sub(countAtom, value => {
console.log("child sees:", value)
})
root.set(countAtom, 1)
// logs: "child sees: 1" — parent update flows through
child.set(countAtom, 99)
// logs: "child sees: 99" — scoped update
root.set(countAtom, 2)
// nothing logged — child has shadowed this atom
Transactions in scopes
Transactions work the same way inside scopes:
child.txn(set => {
set(nameAtom, "New name")
set(ageAtom, 25)
// Both updates are atomic within the scope
})
Cleanup
When you're done with a scope, call detach() to clean it up:
const child = root.scope("temp")
// ... use the scope ...
child.detach()
In framework integrations, cleanup happens automatically when the scope component unmounts.
In React
Use the Scope component to create a scoped store for a subtree:
import { Provider, Scope } from "valdres-react"
import { useValue } from "valdres-react"
function App() {
return (
<Provider>
<MainView />
<Scope scopeId="modal">
<ModalContent />
</Scope>
</Provider>
)
}
Everything inside <Scope> reads and writes to the scoped store. Components outside continue using the parent store.
Initializing scope state
Pass an initialize callback to set up initial values:
<Scope
scopeId="edit-form"
initialize={txn => {
txn.set(nameAtom, "Draft name")
txn.set(emailAtom, "draft@example.com")
}}
>
<EditForm />
</Scope>
Or use the array format:
<Scope
scopeId="edit-form"
initialize={() => [
[nameAtom, "Draft name"],
[emailAtom, "draft@example.com"],
]}
>
<EditForm />
</Scope>
Auto-generated scope IDs
If you omit scopeId, a unique ID is generated automatically:
<Scope>
<IsolatedWidget />
</Scope>
Use cases
Edit modal with cancel
Create a scope for the edit form. If the user cancels, the scope is discarded. If they save, copy the values to the parent:
function EditModal({ userId }) {
const store = useStore()
const handleSave = () => {
store.scope("edit", scopedStore => {
// Copy edited values back to parent
store.set(userAtom(userId), scopedStore.get(userAtom(userId)))
})
}
return (
<Scope scopeId="edit">
<UserForm userId={userId} />
<button onClick={handleSave}>Save</button>
<button onClick={onClose}>Cancel</button>
</Scope>
)
}
Multi-tenant dashboard
Each tenant panel gets its own scope, sharing base configuration but with independent data:
function Dashboard({ tenants }) {
return (
<Provider>
{tenants.map(tenant => (
<Scope
key={tenant.id}
scopeId={tenant.id}
initialize={() => [
[tenantIdAtom, tenant.id],
[tenantNameAtom, tenant.name],
]}
>
<TenantPanel />
</Scope>
))}
</Provider>
)
}
Drag preview
Show a preview of what state will look like without committing:
function DragPreview({ itemId, targetListId }) {
return (
<Scope scopeId="drag-preview">
<MoveItemToList itemId={itemId} listId={targetListId} />
<ListPreview listId={targetListId} />
</Scope>
)
}
Performance
Scoped stores are optimized for minimal overhead:
- No upfront copying — values are resolved lazily by walking up the scope chain
- Selective propagation — when a parent atom updates, the change only propagates to scopes that haven't shadowed it
- Reference counting — scopes are cleaned up when no consumers remain