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

Deleting a member in a scope

del() removes the member from this store's membership. On a scope that is a local removal, and the two read paths answer differently on purpose:

root.set(todoAtom("a"), { title: "Buy milk", done: false })

child.del(todoAtom("a"))

child.get(todoAtom) // [] — not one of the child's members
child.get(todoAtom("a")) // { title: "Buy milk" … } — still the parent's value
root.get(todoAtom) // ["a"] — the parent is untouched

A value read with no local value falls through the scope chain, exactly as it does for any other atom — the scope said "not one of mine", not "gone everywhere". On a root store there is nothing to fall through to, so a deleted member reads its family default instead.

This is also why reverting a scope has to restore membership and not just values: see Reverting a scope below.

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, () => {
    console.log("child sees:", child.get(countAtom))
})

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
})

Reverting a scope

unset drops one shadowed value so the atom re-inherits its parent's:

import { atom, atomFamily, store } from "valdres"

const nameAtom = atom("Alice")
const todoAtom = atomFamily<string, [string]>(id => `todo:${id}`)

const root = store()
root.set(todoAtom("a"), "Buy milk")
const draft = root.scope("draft")

draft.set(nameAtom, "Bob")
draft.unset(nameAtom)
draft.get(nameAtom) // "Alice" — the parent's value again, and tracking it

unsetAll() does that for everything the scope owns, in a single commit:

draft.set(nameAtom, "Bob")
draft.set(todoAtom("b"), "Draft todo")

draft.unsetAll()

draft.get(nameAtom) // "Alice"
draft.get(todoAtom) // [todoAtom("a")] — the draft's own member is gone too

Family membership reverts in both directions: members the scope added leave its get(family), and members it deleted with del() come back.

The scope itself survives — this is the difference from detach(). Its id, subscriptions, nested scopes, and any other handles on it all keep working, and it starts shadowing again the moment you write to it. Reach for unsetAll() when a scope's edits have been applied (or abandoned) but the scope stays on screen; reach for detach() when the scope is done for good.

Every atom the scope shadowed notifies, exactly as unset does — including one whose parent value happens to be identical, because the scope really did stop owning it. Atoms it never shadowed are untouched.

Reverting inside a transaction

A scope's own transaction can revert it directly:

draft.txn(txn => {
    txn.unsetAll()
    txn.set(nameAtom, "a fresh start")
})

More often the revert belongs to a commit the parent is driving — publishing a draft, say — so the whole thing lands atomically and no subscriber sees the scope snap back before the write that supersedes it:

const publishedName = atom("")

root.txn(txn => {
    txn.set(
        publishedName,
        root.scope("draft", s => s.get(nameAtom)),
    )
    txn.scope("draft", scoped => scoped.unsetAll())
})

txn.scope() throws if the scope does not exist, so guard with hasScope when a scope may never have been opened — from inside the transaction callback:

root.txn(txn => {
    if (root.hasScope("draft")) {
        txn.scope("draft", scoped => scoped.unsetAll())
    }
})

Checking before txn() looks equivalent but is not on a batchUpdates store: txn() flushes the pending batch before running your callback, and a subscriber woken by that flush can detach the last lease on the scope you just checked for. Inside the callback the flush has already happened.

unsetAll() is a scope operation — a root store has no parent to revert to, and calling it there throws. It is typed that way too: unsetAll exists on ScopedStore and on the transaction scope() hands you, not on Store or on a root transaction.

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.

detach() releases your lease. The scope itself dies when its last lease goes, so a holder cannot tell from its own detach() whether the scope survived — store.hasScope(scopeId) answers that from the parent:

const first = root.scope("draft")
const second = root.scope("draft")

first.detach()
root.hasScope("draft") // true — second still holds a lease
second.detach()
root.hasScope("draft") // false — gone

Releasing your own resources with a scope

If you keep anything alongside a scope — a cache, a subscription to something external, a timer — register it with onDispose, which runs when the scope actually dies: on its last detach(), or when an ancestor is disposed.

const draft = root.scope(changeSetRef)
draft.onDispose(() => cache.delete(changeSetRef))

Without it the only signal is inference on the next acquire, which cannot distinguish a scope that survived from a new one that reuses the id — so state keyed by the id outlives the scope it belonged to and leaks into its successor.

The store is already terminal inside the callback, so read anything you need beforehand and close over it. onDispose is on every store, not just scopes: a request/SSR root store releases its resources the same way.

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. Edits stay in the scope; saving copies them to the parent and reverts the scope, so the form is clean the next time it opens — and both halves land in one commit:

function EditModal({ userId }) {
    const store = useStore()

    const handleSave = () => {
        store.txn(txn => {
            txn.set(
                userAtom(userId),
                store.scope("edit", scoped => scoped.get(userAtom(userId))),
            )
            txn.scope("edit", scoped => scoped.unsetAll())
        })
    }

    const handleCancel = () => {
        // Throw the edits away without tearing the scope down.
        store.scope("edit", scoped => scoped.unsetAll())
        onClose()
    }

    return (
        <Scope scopeId="edit">
            <UserForm userId={userId} />
            <button onClick={handleSave}>Save</button>
            <button onClick={handleCancel}>Cancel</button>
        </Scope>
    )
}

useStore() inside <Scope> gives you the scope itself, so a "reset this form" button in the form can call store.unsetAll() directly — cast the handle if your adapter types it as a plain Store.

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