# Architecture
`DndProvider` looks like one component, but it creates three cooperating
layers: a reactive state store, a zone registry, and a pure gesture state
machine. `Draggable` and `DropZone` are ordinary consumers of those layers,
and every layer is public, so anything they do, your own components can do.
API reference: [api/core.md](../api/core.md).
Live demo: every gallery page runs on these layers; the
[Archive](https://kindintelligence.github.io/dioxus-dnd/archive) page renders
the registry's live census over ten thousand virtualized rows.
## The mental model
One provider, three layers:
- A `Store<DragState<T>>` in Dioxus context holds the source-compatible drag
record: the payload, source and hovered zones, pointer, grab offset,
requested effect, and input mode. A private runtime sidecar holds stable
identity, tracked sessions, the live modifier-adjusted effect, and terminal
phase. `DndContext<T>` is the cheap `Copy` handle over both.
- A `ZoneRegistry<T>` alongside it records every mounted drop zone: id,
label, drop callback, acceptance filter, DOM handle, cached client rect.
Pointer hit-testing and keyboard navigation are queries against this
registry, not DOM walks.
- A gesture state machine (`core::machine`) decides what a pointer press
means: tap, scroll, or drag. It is a pure transition function, so every
edge case is an exhaustive match arm with a test.
Native boundary events appear nowhere in this picture. They are reserved for
OS file drops and picker selections, external content in, and dragging out.
In-app drags use pointer events plus keyboard, which keeps the payload a Rust
value and the visuals under your control.
## The state store
The payload travels through the store as a plain `Clone` value: no JSON, no
string ids, no serialization. Stores are Dioxus 0.7's fine-grained
reactivity primitive - each field of `DragState` gets its own lazy
subscription, and every `DndContext` accessor reads through a per-field
lens. A component that calls `dnd.over()` in its render reruns only when the
hovered zone changes, not on every pointer move:
```rust,ignore
let dnd = use_dnd::<Card>();
let lit = dnd.over() == Some(MY_ZONE); // reruns on hover change only
let ghost_at = dnd.pointer(); // reruns per move - overlays only
```
Writes are granular too: `update_pointer` notifies only `pointer`
subscribers, `enter` and `leave` only `over` subscribers. This is why a
board with hundreds of zones stays smooth mid-drag - per-move work is
confined to the components that actually track the pointer.
## The zone registry
Every mounted `DropZone` registers itself and unregisters on unmount, so
the registry always mirrors what is on screen - a virtualized list with ten
thousand rows keeps only the mounted few dozen registered. Two query
families power the built-in interactions:
- **Pointer hit-testing.** Move-time hover uses `resolve_hover`, which filters
the full `DropQuery` and ranks candidates through the provider's collision
policy. Release selection uses `resolve`, applying the same acceptance and
effect negotiation before optional nearest-zone recovery. The legacy
`hit_test` and `hit_test_closest` helpers remain available to custom code
that only needs geometry or payload-only acceptance.
Registration order only approximates paint order: CSS stacking and portals
are not inspected.
- **Keyboard navigation.** `step_zone` and its sibling/child variants walk
acceptable zones in spatial order (top-to-bottom, then reading order,
mirrored under `Direction::Rtl`); tops within one CSS pixel form a row so
fractional layout does not produce a zig-zag. Zone labels feed the
screen-reader announcements.
Rects are cached, measured fresh at pickup. When layout moves under a live
drag - scrolling, a collapsing panel - the `RectRefresh` channel
(`use_rect_refresh`) pings every provider in the tree to re-measure. Once a
provider's asynchronous measurement batch settles it resolves hover again
from the current pointer, live effect, drag identity, and session. That keeps
a resting pointer accurate while auto-scroll moves targets and prevents an
old batch from touching a replacement drag. Idle providers ignore the ping,
so wiring it to raw scroll events costs nothing; `AutoScroll` pings it for
you.
## The gesture machine
A press is not yet a drag. The lifecycle - press, promotion, tracking,
release or abort - is a formal state machine over three phases (`Idle`,
`Pressed`, `Dragging`) with two promotion policies:
- `Promotion::Distance`: travel past the threshold in any direction begins
the drag. Right for mouse, pen, and touch surfaces that own every gesture.
- `Promotion::HoldOrSideways`: a 250ms hold or a sideways-dominant pull
begins the drag, while a vertical-dominant pull yields the gesture to
native scrolling. This is `TouchSense::Auto`, the reason draggables in a
scrollable list do not fight the finger.
`transition` and `transition_with` are pure functions: same inputs, same
outputs, no side effects. Stray inputs - foreign pointer ids, a second
finger pressing, a hold timer firing after the gesture resolved - are
deliberately inert, each one a tested match arm rather than an ad-hoc `if`.
`Draggable` drives this machine; you can drive it yourself.
## Callback boundaries are generation boundaries
Drop delivery crosses synchronous user-code boundaries: the receiving
`on_drop`, the source completion handler, and component cleanup may unmount
participants or begin replacement drag N+1 before drag N's call stack
returns. The ordering is therefore a protocol, not an implementation detail:
1. Commit N's source result before calling receiver code. If that callback
begins N+1, `begin_from` mints a fresh world generation and refuses to
attach N's already-committed source session.
2. Take N's completion slot before invoking its source callback, so callback
reentrancy cannot consume the old slot twice.
3. After user code, finishing N re-reads source-session ownership and the live
dragging state. It clears metadata only while N still owns it and no
replacement is active.
4. Separately, host poller and release work captures `(world generation,
source session)` and revalidates both immediately before every action, so
stale host evidence becomes inert as soon as N completes or N+1 begins.
Never clear shared world state from a pre-callback observation, and never
treat `session: None` alone as authority: untracked sources still have a
mandatory world generation. These rules are load-bearing across
`core/world/drag.rs` and the desktop release/poller legs; tests deliberately
start replacements from inside receiver and source callbacks to keep the
boundary reentrant.
## Build your own
Because the layers are public, a custom drop target is just a component
that registers itself and reads the context. This is a working bare zone:
```rust,ignore
#[component]
fn TrashZone(on_trash: EventHandler<DropOutcome<Card>>) -> Element {
let dnd = use_dnd::<Card>();
let mut registry = use_zone_registry::<Card>();
let id = use_zone_id();
let registration = use_hook(|| {
let mut record = ZoneRecord::new(
id,
Callback::new(move |o| on_trash.call(o)),
);
record.label = Some("Trash".into());
registry.register(record)
});
use_drop(move || registry.unregister(id));
let armed = dnd.dragging();
let over = dnd.over() == Some(id);
rsx! {
div {
onmounted: move |evt| {
let m = evt.data();
registry.set_mounted(registration, m.clone());
spawn(async move {
if let Ok(r) = m.get_client_rect().await {
registry.set_rect_if_present(registration,
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height));
}
});
},
class: if over { "trash hot" } else if armed { "trash armed" } else { "trash" },
"Trash"
}
}
}
```
Registration is what buys the behavior: pointer drops land here, keyboard
navigation reaches it and announces "Trash", near-miss releases snap to it.
The [Standup](https://kindintelligence.github.io/dioxus-dnd/standup) gallery
page pushes this recipe further, registering one element in two payload
worlds at once.
## Gotchas
- **`records()` subscribes, the id lookups peek.** Rendering from
`registry.records()` reruns on every mount and unmount - right for
devtools, wrong inside a hot zone. `get`, `cached_rect` and
`mounted_handle` never subscribe.
- **`dragging()` is false while a drop settles.** During the overlay's
settle glide, `payload()` stays readable so the ghost keeps its content,
but zones have already unlit. Check `settling()` when the distinction
matters.
- **Do not match the growing enums exhaustively.** `DragMode`,
`PointerKind` and `GestureEvent` are `non_exhaustive`; new input paths
arrive as new variants. Match what you handle and let the rest fall
through.
- **Explicit `ZoneId`s belong below 2^32.** The registry replaces records
by id, so a collision silently knocks a zone out. Auto ids start at 2^32
precisely so hand-written `u32`-range ids can never collide.
- **An exact (0,0) pointer is dropped.** `update_pointer` treats it as a
bogus platform report, so a custom source feeding synthetic moves never
sees the overlay jump to the corner.
## Related
- [Drag and drop](drag-and-drop.md): the components built on these layers.
- [Virtualized lists](virtualized-lists.md): the registry's
mount-and-measure model doing its best work.
- [Multi-window desktop drags](multi-window.md): several windows joining
one shared world built from the same context.
- [Testing](testing.md): `DragSim` drives the production delivery path
through these same layers, no browser.
- [Debugging](debugging.md): the overlay renders the registry live.