What is a citizen?
Before any of the trait, dispatcher, or reactive-state machinery, get the picture from the UI side first.
A citizen is a panel. A docked, movable, resizable region of the application window with a stable identity and a known set of widgets inside it.
That's the user-facing definition. Everything else — the Citizen
trait, the Dispatcher, the reactive CitizenState — is the
plumbing that makes the behavior of those panels predictable across
an application. The plumbing matters, but the panel is what the user
actually sees and interacts with.
The general characteristics
A citizen panel has all of the following:
-
Identity. Each panel has a stable name (a
CitizenId—"plot","settings","logger","gerber_view"). Two panels cannot share an identity within the same app. The identity outlives any individual frame and survives layout changes. -
Dockable. The panel slots into the application's dock layout via
egui_dock(or a sibling dock library). It can sit alongside other citizen panels in a tabbed group, in a split, or as a free-floating window. -
Movable. The user can drag the panel by its tab bar to a different dock position. Citizen identity is preserved through the move — the panel knows it's still the same panel after landing in a new spot.
-
Resizable. Dock split handles let the user reapportion space between citizens. The panel adapts; it does not lose state when its size changes.
-
Atomic content. A panel contains atoms — the widgets inside it: a slider, a button, a checkbox, a text field, a scrollable list, a plot. Atoms are the panel's interactive surface. A panel without atoms is a static label; the citizen pattern shines when atoms drive shared state that other citizens observe.
-
Lifecycle awareness. At any moment exactly one citizen is the active one in its group. Activation flips when the user clicks the panel's tab. The pattern guarantees this is exclusive — when "alpha" activates, "beta" deactivates atomically. Other panels can react to this without polling.
-
Reactive state. A small bundle of
Dynamic<T>cells — active, clicked, selected, visible, location, moved — published by every citizen and readable from anywhere in the application. Other panels and backend threads observe these without holding references to the panel itself.
What this buys you that egui_dock alone doesn't
egui_dock is a layout library. It tells you which panel is
visible on screen and which sits in which split. It does not
tell you which panel is currently the active citizen of
interest — i.e., which panel the user clicked last, which one
should be receiving keyboard focus, which one a backend thread
should be feeding fresh data to.
The citizen pattern fills that gap by giving every panel its
own reactive state (CitizenState) and routing tab clicks
through a central Dispatcher. The dispatcher's activate(...)
call is an atomic set/reset: when "alpha" becomes active, every
other citizen's active cell flips to false in the same
operation, and lifecycle messages (Activated { id: alpha },
Deactivated { id: beta }) drop into the dispatcher's queue.
This means:
- A backend thread can poll the dispatcher (or observe each
citizen's
activeDynamic<bool>) and discern which citizen is currently of interest without holding a reference to any panel. Background work — fetching data, running computations, reading hardware — knows where to direct its results. - A sibling panel can react to another panel becoming active without any per-frame polling: the reactive cell delivers the change, the rendering panel re-reads on its next frame.
- The pattern guarantees one-hot activation — exactly one
citizen active at a time per group — atomically. Two panels
can never both think they're active because of a frame-order
race.
egui_dockmakes no such guarantee; you'd have to wire it manually.
This separation — dock library handles geometry, citizen dispatcher handles interest — is the load-bearing distinction. Without it, every app reinvents some ad-hoc "which panel did the user mean?" logic. With it, that's framework infrastructure you inherit for free.
Citizens as plug-ins
The other consequence of the citizen contract is that citizens
become plug-ins. Once a panel implements the Citizen trait,
exposes its reactive state, and integrates with the dispatcher,
it doesn't need to know anything about the host app to drop in.
The host app, conversely, just needs to:
- Add the citizen's crate as a dependency.
- Carry its
Dynamic<T>state field on the shared state struct. - Add a
TabKindvariant for it. - Render it from the
TabViewer.
That's the whole integration. No glue code, no event-bus wiring, no manual subscription setup. The citizen pulls its weight as a self-contained unit. And the Dispatcher is the registry those plug-ins register with — the same registry pattern familiar from backend systems, applied here to UI panels.
egui_lens (the reactive event logger) and egui_quill (the
syntax-highlighted editor) are the canonical examples of this in
action. Both ship as their own workspace crates with stable
public APIs (ReactiveEventLogger + ReactiveEventLoggerState;
ReactiveEditor + ReactiveEditorState). The host app's
integration is small enough to fit on a sticky note:
// One field on shared state.
pub log: Dynamic<ReactiveEventLoggerState>,
// One render call per frame.
let logger = ReactiveEventLogger::new(&state.log);
logger.show(ui);
Same pattern for quill, same pattern for the canonical citizen
panels coming next — Project, Settings, Terminal, Data Table.
Same pattern for any third-party citizen someone publishes on
crates.io: cargo add egui_their_panel, declare the state
field, render in a tab, done.
This is what makes the framework genuinely composable rather than just architecturally tidy. Real apps grow by accumulating citizens, not by extending their core.
Caveat: citizens are compile-time plug-ins — adding one rebuilds the host app, not a runtime extension load. The Rust toolchain doesn't ship a stable plug-in ABI; runtime loading would require dynamic library tricks that aren't worth the complexity. The four-line integration is small enough that it feels plug-in-shaped in practice, and the rebuild is fast.
What it is not
A citizen is not:
- A modal dialog or a popover. Those have transient lifetimes and no stable identity.
- A widget. Widgets are atoms; they live inside citizens.
- A non-docked sub-region of a single window. The dockability is intrinsic; without it, a panel is just a layout container.
- A backend thread. Background work runs separately and communicates with citizens through reactive state and message channels.
A picture
┌───────────────────────────────────────────────────────────────┐
│ App window │
│ ┌──────────────┬──────────────────────┬──────────────────────┐ │
│ │ ▶ Settings ✕ │ ▶ Plot ✕ │ ▶ Logger ✕ │ │
│ ├──────────────┼──────────────────────┼──────────────────────┤ │
│ │ │ │ │ │
│ │ citizen │ citizen │ citizen │ │
│ │ "settings" │ "plot" │ "logger" │ │
│ │ │ │ │ │
│ │ atoms: │ atoms: │ atoms: │ │
│ │ - slider │ - plot widget │ - filter btn │ │
│ │ - combobox │ - link checkbox │ - clear btn │ │
│ │ - generate │ │ - save btn │ │
│ │ button │ │ - column toggles│ │
│ │ │ │ - scroll list │ │
│ └──────────────┴──────────────────────┴──────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
Three citizens. Each is dockable / movable / resizable. Each holds atoms that are the user's actual interaction surface. Reactive state flows through the framework's plumbing so the plot panel can react to a settings-panel atom, and the logger can show a backend-thread message, without any of them needing direct references to the others.
Where the rest of the book goes from here
- The Citizen trait chapter is the code
shape: what
impl Citizen for MyPanellooks like, the lifecycle hooks, theCitizenIdandCitizenStatetypes. - The Dispatcher chapter is the coordinator: how activation propagates and how messages drain.
- The
Dynamic<T>background chapter is the reactive primitive everyCitizenStatefield rests on. - The tutorial is the worked example — three citizens (Plot / Settings / Logger), built end to end.
Chapter last revised: 2026-05-04 — egui_mobius v0.4.0.