The Dispatcher
The Dispatcher is a registry of citizens. Each citizen registers
a CitizenState handle with it, and the registry coordinates one-hot
activation and message buffering across the registered set. If you've
built backend systems, this is the same pattern you've seen before — a
central table that knows what's plugged in and routes accordingly. The
citizens-as-plug-ins framing
is the other side of this contract: plug-ins register, the registry
coordinates, no party needs direct references to the others.
It is also opt-in infrastructure, not the entry point of an
egui_citizen app. Many apps that share state between panels through
Dynamic<T> never reach for one. You reach for a dispatcher when the
reactive primitives don't already give you what you need:
- One-hot activation arbitration. Exactly one panel "active" at a time, atomically, across an arbitrary number of citizens. Useful any time focus, selection, or panel priority matters — even in a pure-UI app with no backend.
- A queue for outbound events. A place to push events that the update loop drains once per frame and forwards onward to backend threads, loggers, persistence, or anywhere else outside the UI tick.
Whichever of those you need, the dispatcher does three jobs:
- Owns the registered citizens'
CitizenStatehandles. Every citizen you callregister()on has itsCitizenStatecloned into aHashMapinside the dispatcher. Panels hold the other clone; both refer to the sameArc-backed storage. - Enforces the one-hot activation invariant.
Dispatcher::activate(&id)sets the named citizen'sactiveflag totrueand clears every other registered citizen's flag, atomically. - Buffers outbound messages. Lifecycle changes and explicit
send()calls accumulate in a queue that you drain once per frame.
Why the dispatcher, not just shared state?
If panels can share state through Dynamic<T> clones already, what
does the dispatcher add?
Three things that don't fall out of shared Dynamic<T> alone:
- Atomic one-hot activation.
activate(&id)flips one citizen'sactivetotrueand clears every other registered citizen's flag in a single call. Doing this with shared state alone means wiring each panel to clear every other panel's flag — N² coordination and a new wire every time a panel is added. - Lookup by stable id. Panels and backend threads address each
other by
CitizenId, not by holding pointers to one another's structs. The dispatcher is the directory; backend threads in particular have no other way to find the right reactive cell to write to. - Frame-aligned event buffering.
activate()andsend()push lifecycle events into a queue thatdrain_messages()consumes once per frame. This is the seam between event-time (a tab was clicked just now) and frame-time (work reacts to it next tick) — backend threads see batched updates rather than per-mutation callbacks.
And three things it does not do, each a common confusion:
- It is not a runtime gateway. Buffering events is not the same as
performing the work those events trigger. The dispatcher does not
spawn threads, schedule async tasks, or own
JoinHandles. Its queue is the interface to the runtime boundary; the boundary itself is the backend thread your app starts and feeds fromdrain_messages(). - It is not a reactive bus. The registry tracks
CitizenState(six lifecycle fields) and nothing else. Your slider'sDynamic<f32>is invisible to the dispatcher until you explicitly callsend(). See the coupling chapter for why that asymmetry is deliberate. - It does not observe dock layout or fire UI events on its own. A
tab click triggers
activate(...)because youron_tab_buttoncalls it; the dispatcher never spies on the dock and fires activations for you. Give it an event, it routes; don't give it an event, it sits idle.

Registration topology. Every panel hands its CitizenState to the
one dispatcher; the dispatcher keeps a clone in its table while the
panel keeps the other clone. Both refer to the same Arc-backed
storage, which is exactly why activations issued from the dispatcher
become immediately visible to every panel that holds a clone.
API surface
pub struct Dispatcher { /* private */ }
impl Dispatcher {
pub fn new() -> Self;
pub fn register(&mut self, id: CitizenId) -> CitizenState;
pub fn get(&self, id: &CitizenId) -> Option<&CitizenState>;
pub fn send(&mut self, message: CitizenMessage);
pub fn activate(&mut self, id: &CitizenId);
pub fn drain_messages(&mut self) -> Vec<CitizenMessage>;
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
}
Eight methods. The four that matter day-to-day are register,
activate, drain_messages, and send.
register(id) -> CitizenState
let plot_state = dispatcher.register(CitizenId::new("plot"));
let plot = PlotPanel::new(plot_state);
Registers a citizen and hands you back a CitizenState handle into
the dispatcher's storage. The dispatcher keeps a clone of that handle
in its table; you take the other clone and pass it to your panel
struct. Both point at the same underlying Arc<Mutex<...>> storage,
so writes by either side are immediately visible to the other (see
reactive lifecycle: clones share storage).
This is the only correct way to obtain a CitizenState for a
panel that the dispatcher will activate. CitizenState::default()
allocates fresh disconnected storage; the dispatcher and the panel
end up holding different Arcs and the reactive link is silently
severed.
activate(&id)
dispatcher.activate(&CitizenId::new("plot"));
The encoded set/reset. After this call:
- The named citizen's
activeflag istrue. - Every other registered citizen's
activeflag isfalse. - The message queue contains an
Activated { id }for the named citizen — always, even if the citizen was already active in the previous frame. - The queue contains
Deactivated { id }for each citizen that was previously active and has now been turned off. (Citizens that were already off do not produce aDeactivated.)
Call activate() from a user-driven event. For egui_dock apps,
that's TabViewer::on_tab_button when response.clicked(). Do
not call it from the render path unconditionally — that fires
Activated (and possibly Deactivated) every frame and floods the
queue. See pitfalls.
drain_messages() -> Vec<CitizenMessage>
for msg in dispatcher.drain_messages() {
match msg {
CitizenMessage::Activated { id } => { /* ... */ }
CitizenMessage::Deactivated { id } => { /* ... */ }
_ => {}
}
}
Removes and returns all pending messages. Call once per frame, after
DockArea::show() (so that on_tab_button has had its chance to
call activate()). The queue is now empty until the next activate
or send produces more.
If you forget to drain, the queue grows forever. No errors, no warnings — just a slow memory leak.
send(message)
dispatcher.send(CitizenMessage::Clicked { id: alpha_id.clone() });
Pushes a message onto the queue without going through activate().
Common uses:
- Custom lifecycle events. Emit
Selected,Moved,VisibilityChangedfrom app-level code that detects them — the dispatcher only emitsActivated/Deactivatedon its own. - App-message bridging. A backend thread that needs to inject a citizen-shaped event back into the UI loop.
- Testing / replay. Replaying a recorded session from a log file.
The dispatcher does no validation here — it accepts any
CitizenMessage you give it.
Worked example
use egui_citizen::{CitizenId, CitizenMessage, Dispatcher};
let mut dispatcher = Dispatcher::new();
let alpha = dispatcher.register(CitizenId::new("alpha"));
let beta = dispatcher.register(CitizenId::new("beta"));
dispatcher.activate(&CitizenId::new("alpha"));
let msgs = dispatcher.drain_messages();
// msgs == [Activated { id: alpha }]
// (beta was never active, so no Deactivated)
dispatcher.activate(&CitizenId::new("beta"));
let msgs = dispatcher.drain_messages();
// msgs == [Activated { id: beta }, Deactivated { id: alpha }]
assert!(beta.active.get());
assert!(!alpha.active.get());
One dispatcher per app
The one-hot invariant is per-dispatcher. Two dispatchers in the
same app each maintain their own one-hot, and activate on one does
not deactivate citizens registered on the other. If two halves of
your UI ever race over "who is active," you have two dispatchers (or
worse, two CitizenStates for the same logical citizen).
The dispatcher typically lives on the app struct:
struct App {
dispatcher: Dispatcher,
plot: PlotPanel,
settings: SettingsPanel,
/* ... */
}
Backend threads that need to send messages do so via a
crossbeam_channel whose receiver lives on the UI thread; the UI
thread drains the channel and forwards messages with
dispatcher.send(). The Dispatcher itself is &mut self-only and
is not shared across threads directly.
Summary
register()is the only correct way to obtain a panel'sCitizenState.activate()is an encoded set/reset; call it on user-driven events, never every frame unconditionally.drain_messages()is the once-per-frame backend boundary; forgetting to call it leaks memory.send()is for explicit Path B messages outside the activation flow.- One dispatcher per app — never two.