← Back to list

GLoC Part 2 — State, Reactors, and the Art of Knowing When Things Change

This is Part 2 of the GLoC series. In Part 1 we built a counter to get a feel for the library. Now we slow down and really understand the…

Godwin Joseph Kurinjikattu · 2026-06-05 15:12 · 2 claps · 6.0 min read
#rust #gloc #rustlang
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing 🌐 · Web Development 📚 · Books & Reading

GLoC Part 2 — State, Reactors, and the Art of Knowing When Things Change

This is Part 2 of the GLoC series. In Part 1 we built a counter to get a feel for the library. Now we slow down and really understand the architecture — because once these concepts click, everything else in GLoC makes sense immediately. Read Part 1 **here**

The Central Question: Who Owns What?

Most state management bugs trace back to a single root cause — state is owned by nobody, or everybody. It gets copied into components, passed through props, cached in globals, mutated from unexpected places. You end up debugging which version of the truth you’re looking at.

GLoC’s answer is deliberate: one slice of state, one owner, one source of truth. That owner is the Reactor.

Let’s build up to it from first principles.

State — Just Data, With Rules

In GLoC, State is any type that satisfies three bounds:

Clone + PartialEq + Debug

That’s the entire contract. No base class. No trait implementation. No registration with a global registry. If your type satisfies those three bounds, it’s a valid GLoC state.

The #[reactor_state] macro exists purely as a convenience — it injects the derives so you don't repeat yourself:

#[reactor_state]
pub struct CartState {
    pub items: Vec<String>,
    pub total: f64,
}

// Expands to:
#[derive(Clone, PartialEq, Debug)]
pub struct CartState {
    pub items: Vec<String>,
    pub total: f64,
}

State can be a struct, an enum, or a primitive. GLoC doesn’t care about shape — only the bounds matter:

// Struct — rich domain object
#[reactor_state]
pub struct UserState {
    pub name: String,
    pub logged_in: bool,
    pub role: UserRole,
}
// Enum — perfect for loading flows
#[reactor_state]
pub enum FetchState {
    Idle,
    Loading,
    Success(String),
    Error(String),
}
// Even a primitive works
type CounterState = i32;

The enum pattern is particularly powerful. Instead of is_loading: bool + error: Option<String> + data: Option<T> — three fields that can contradict each other — you get one enum where only valid combinations are representable.

Change Detection Is Built In

Here’s where PartialEq earns its place. When you call emit(), GLoC doesn't blindly fire the stream — it compares first:

reactor.emit(CounterState { count: 0 }); // current count is 0 → equal → silent
reactor.emit(CounterState { count: 1 }); // different → stream fires
reactor.emit(CounterState { count: 1 }); // same again → silent

This is subtle but important. It means:

  • No spurious re-renders from emit() calls that don't actually change anything
  • You can call emit() defensively — normalize your state and let GLoC figure out if anything changed
  • Derived computations and side effects only run when they need to

The cost of the comparison is the cost of PartialEq on your state type — typically O(1) for simple structs, predictably bounded for everything else.

Reactor — The Owner

The Reactor is the heart of GLoC. It owns one slice of state, exposes domain methods to mutate it, and carries a built-in reactive stream. Think of it as a self-contained state machine with a broadcast channel built in.

There are two ways to define one.

Mode A — Bring Your Own State

You define the state separately, then attach it to the reactor:

#[reactor_state]
pub struct CounterState {
    pub count: i32,
}
#[reactor(state = CounterState)]
pub struct CounterReactor {}
impl CounterReactor {
    pub fn increment(&mut self) {
        self.emit(CounterState { count: self.count + 1 });
    }
    pub fn decrement(&mut self) {
        self.emit(CounterState { count: self.count - 1 });
    }
    pub fn reset(&mut self) {
        self.emit(CounterState { count: 0 });
    }
}

Use Mode A when your state type is shared, reused across reactors, or you want to define it independently for testing purposes.

Mode B — Let GLoC Generate the State

You annotate fields directly on the reactor and the macro generates the state struct for you:

#[reactor]
pub struct ToggleReactor {
    #[state] pub active: bool,
    #[state] pub label: String,
}
// GLoC silently generates:
// #[derive(Clone, PartialEq, Debug)]
// pub struct ToggleReactorState {
//     pub active: bool,
//     pub label: String,
// }

Use Mode B for quick iteration — fewer files, less ceremony. The generated state type follows the naming convention {ReactorName}State.

What the Macro Actually Generates

Both modes produce the same underlying structure. Understanding what gets generated removes the magic:

  • **impl Reactor** — Wires state(), emit(), and stream()
  • **new(initial)** — Constructor that takes the initial state
  • **impl Deref<Target = State>** — Direct field access: reactor.count instead of reactor.state().count
  • **fire(neutron)** — Event dispatcher, only when neutrons = N is set

The Deref implementation is the detail most people notice first in practice. Instead of:

let count = reactor.state().count;

You write:

let count = reactor.count; // Deref makes this work

Both are valid. The explicit state() form is clearer when you want to be intentional about "I'm accessing state here." The Deref form is ergonomic for read-heavy code.

Neutron — The Trigger

The name follows GLoC’s nuclear fission metaphor. A neutron strikes the nucleus, causing a chain reaction. In GLoC, a Neutron is an event fired at a reactor.

Any type satisfying Debug + Send + 'static is automatically a Neutron. No registration, no trait implementation:

#[derive(Debug)]
pub enum AuthEvent {
    Login { username: String, password: String },
    Logout,
    RefreshToken,
}

To enable event dispatch on a reactor, add neutrons = YourEvent to the macro:

#[reactor(state = AuthState, neutrons = AuthEvent)]
pub struct AuthReactor {}
impl AuthReactor {
    fn on_event(&mut self, event: AuthEvent) {
        match event {
            AuthEvent::Login { username, .. } => {
                self.emit(AuthState {
                    logged_in: true,
                    user: username,
                });
            }
            AuthEvent::Logout => {
                self.emit(AuthState {
                    logged_in: false,
                    user: String::new(),
                });
            }
            AuthEvent::RefreshToken => {
                // token refresh logic
            }
        }
    }
}

The macro generates fire(). You write on_event(). The split is intentional — dispatch is infrastructure, the handler is your logic:

reactor.fire(AuthEvent::Login {
    username: "alice".into(),
    password: "***".into(),
});
reactor.fire(AuthEvent::Logout);

Domain Methods vs Neutrons — When to Use Which?

Both approaches mutate state. Which should you reach for?

Domain methods (reactor.logout()) are better for:

  • Simple, direct mutations
  • When the caller knows exactly what operation they want
  • Internal reactor-to-reactor calls

Neutrons (reactor.fire(AuthEvent::Logout)) are better for:

  • Event-driven flows where the reactor decides what to do
  • When multiple callers trigger the same logical event
  • Cross-thread dispatch (neutrons are Send)
  • Decoupling the caller from the reactor’s internal logic

They’re not mutually exclusive — you can expose both on the same reactor:

reactor.logout();                    // domain method
reactor.fire(AuthEvent::Logout);     // neutron — same result

The Architecture So Far

At this point you have everything you need to model your application’s data layer cleanly:

┌─────────────────────────────────────────┐
│                 Reactor                 │
│                                         │
│   State ──► emit() ──► change detect   │
│                              │          │
│   Neutron ──► fire()         │          │
│                  │           ▼          │
│             on_event()   GlocStream     │
│             (your logic)               │
└─────────────────────────────────────────┘

State is the data. emit() is the only way to change it. fire() is how external events enter the system. GlocStream is how change leaves the reactor and reaches the rest of the world.

We’ll cover GlocStream, sharing, and observation in Part 3.

A Real Example — Shopping Cart

Let’s put it together with something more realistic than a counter. A shopping cart with two modes — enum state for async operations, struct state for the cart itself:

#[reactor_state]
pub enum CartLoadState {
    Idle,
    Loading,
    Ready,
    Error(String),
}
#[reactor_state]
pub struct CartState {
    pub items: Vec<CartItem>,
    pub total: f64,
    pub coupon: Option<String>,
}
#[derive(Debug)]
pub enum CartEvent {
    AddItem(CartItem),
    RemoveItem(String),   // item id
    ApplyCoupon(String),
    Checkout,
}
#[reactor(state = CartState, neutrons = CartEvent)]
pub struct CartReactor {}
impl CartReactor {
    fn on_event(&mut self, event: CartEvent) {
        match event {
            CartEvent::AddItem(item) => {
                let mut items = self.items.clone();
                items.push(item);
                let total = items.iter().map(|i| i.price).sum();
                self.emit(CartState { items, total, ..self.state().clone() });
            }
            CartEvent::RemoveItem(id) => {
                let items: Vec<_> = self.items
                    .iter()
                    .filter(|i| i.id != id)
                    .cloned()
                    .collect();
                let total = items.iter().map(|i| i.price).sum();
                self.emit(CartState { items, total, ..self.state().clone() });
            }
            CartEvent::ApplyCoupon(code) => {
                self.emit(CartState {
                    coupon: Some(code),
                    ..self.state().clone()
                });
            }
            CartEvent::Checkout => {
                // trigger checkout flow
            }
        }
    }
}

Notice a few things:

  1. The cart state itself is a struct — it has multiple fields that evolve together
  2. The load/error lifecycle is a separate enum reactor — they’re different concerns
  3. on_event is a pure state machine — given an event, produce a new state
  4. emit() only fires the stream if something actually changed — calling AddItem with a duplicate item that produces the same state is a no-op

Summary

  • **State** — Any Clone + PartialEq + Debug type. Use it as your domain data model.
  • **#[reactor_state]** — Derive macro shorthand. Use it on every state type.
  • **Reactor (Mode A)** — State defined separately. Use it for shared/reused state types.
  • **Reactor (Mode B)** — State generated from fields. Use it for fast iteration and simple reactors.
  • **emit()** — The only way to update state. Call it inside reactor methods.
  • **Neutron** — An event fired at a reactor. Use it for event-driven flows and cross-thread dispatch.
  • **fire()** — Dispatches a neutron. Use it from external callers and UI events.

Next up: Part 3 — Streams, Sharing, and Observation. We’ll cover GlocStream, ListenerHandle, GlocProvider, GlocListener, and GlocObserver — the entire reactive layer that makes your data actually move.

GitHub: https://github.com/godwinjk/gloc Crates.io: https://crates.io/crates/gloc


메타데이터
post_id
3a30f99ebaa2
slug
gloc-part-2-state-reactors-and-the-art-of-knowing-when-things-change-3a30f99ebaa2
url
https://medium.com/@godwinjoseph.k/gloc-part-2-state-reactors-and-the-art-of-knowing-when-things-change-3a30f99ebaa2
canonical_url
https://medium.com/@godwinjoseph.k/gloc-part-2-state-reactors-and-the-art-of-knowing-when-things-change-3a30f99ebaa2
author_url
https://medium.com/@godwinjoseph.k
status
ok
fetched_at
2026-06-17 08:20:12