Reducer Pattern
Deep dive into Iris Studio's reducer layer within today's hybrid Studio architecture.
What is a Reducer?
A reducer is a focused state transition function that takes the current state and an event, then returns any side effects needed after applying the transition:
(state, event) → (state', effects)Key properties:
- I/O-free — No direct disk/network/process work
- Predictable — Same inputs always produce same outputs
- Testable — Easy to unit test reducer paths in isolation
- Traceable — Can log every state transition
Why Use a Reducer?
Traditional imperative UI code scatters state mutations everywhere:
// BAD: State mutations scattered throughout handlers
fn handle_key(&mut self, key: Key) {
if key == 'g' {
self.generating = true;
self.spawn_agent(); // Side effect!
self.status = "Thinking...";
}
}Problems:
- Hard to test (needs mocking)
- Hard to trace (what changed when?)
- Hard to debug (where did this state come from?)
- Race conditions with async code
With a reducer, the important cross-mode transitions flow through one function:
// GOOD: Single source of truth
fn reduce(state: &mut State, event: Event) -> Vec<Effect> {
match event {
Event::GenerateCommit { .. } => {
state.generating = true;
state.status = "Thinking...";
vec![Effect::SpawnAgent { task: Commit }]
}
}
}Benefits:
- Single place to look for state changes
- State logic separate from I/O
- Easy testing — no mocking needed
- Audit trail — log every
(state, event, effects)triple
Current Reality
Studio is no longer a fully pure reducer architecture end-to-end.
- Handlers still perform direct synchronous UI mutations for immediate interactions
StudioAppapplies async results, loaded data, and some coordination state directly- The reducer remains the central event-processing layer for shared workflows and explicit effects
This page focuses on that reducer layer, not on an exclusivity guarantee.
Reducer Submodules
reducer/ is a directory, not a single file. mod.rs owns the top-level match on StudioEvent and delegates per-domain logic to sibling files so each one stays focused:
| File | Owns |
|---|---|
mod.rs | The reduce() entry point, navigation events, lifecycle events, key/mouse plumbing |
agent.rs | AgentStarted/Progress/Complete/Error, streaming chunks |
content.rs | UpdateContent (tool-triggered UI edits), StageFile/UnstageFile |
git.rs | FileStaged/Unstaged, RefreshGitStatus, file log + global log |
modal.rs | create_modal, apply_ref_selection |
navigation.rs | apply_scroll across all modes and panels |
settings.rs | Preset, gitmoji, emoji, amend-mode toggles |
ui.rs | Notifications, scroll wrapper, edit-mode toggle, message-variant cycling, clipboard |
The dispatcher in mod.rs looks like a fat match, but most arms call straight into a one-line helper such as agent::agent_complete(state, history, task_type, result) or git::toggle_global_log(state).
The Reducer Function
Located in src/studio/reducer/mod.rs:
pub fn reduce(
state: &mut StudioState,
event: StudioEvent,
history: &mut History,
) -> Vec<SideEffect>Parameters:
state— Mutable reference to application stateevent— The event to processhistory— Mutable reference to event history
Returns:
Vec<SideEffect>— Side effects to execute after state update
Why Mutate State Directly?
Some reducer patterns return a new state (pure functional style). We mutate in-place for performance:
// Pure functional (expensive for large state)
fn reduce(state: State, event: Event) -> (State, Vec<Effect>) {
let mut new_state = state.clone(); // Full clone!
// ... mutations ...
(new_state, effects)
}
// In-place mutation (what we use)
fn reduce(state: &mut State, event: Event) -> Vec<Effect> {
// Direct mutations
state.mode = Mode::Commit;
effects
}We still get predictability because:
- All mutations happen in one function
- We log the before/after state in history
- We can replay events to reconstruct state
Event Processing Flow
┌─────────────────────────────────────────────────────────────┐
│ Event Loop │
│ │
│ 1. Pop event from queue │
│ │ │
│ ▼ │
│ 2. Call reduce(state, event, history) │
│ │ │
│ ├──▶ Match event variant │
│ ├──▶ Update state fields │
│ ├──▶ Record to history │
│ └──▶ Build effect list │
│ │ │
│ ▼ │
│ 3. Return effects │
│ │ │
│ ▼ │
│ 4. Execute effects (app/mod.rs) │
│ │ │
│ ├──▶ SpawnAgent → tokio::spawn │
│ ├──▶ GitStage → git add │
│ ├──▶ LoadData → async load │
│ └──▶ Effects emit new events │
│ │ │
│ └──▶ Back to event queue │
│ │
└─────────────────────────────────────────────────────────────┘Anatomy of an Event Handler
Let's trace GenerateCommit:
StudioEvent::GenerateCommit {
instructions,
preset,
use_gitmoji,
amend,
} => {
// 1. Update UI state
state.modes.commit.generating = true;
let thinking_msg = if amend {
"Generating amended commit message..."
} else {
"Generating commit message..."
};
state.set_iris_thinking(thinking_msg);
// 2. Record to history
history.record_agent_start(TaskType::Commit);
// 3. Build effect
effects.push(SideEffect::SpawnAgent {
task: AgentTask::Commit {
instructions,
preset,
use_gitmoji,
amend,
},
});
}Notice:
- State mutations happen immediately
- No async/await in the reducer path
- Effect describes what to do, doesn't do it
- History records the transition
Side Effects
Effects are data describing I/O operations:
#[derive(Debug, Clone)]
pub enum SideEffect {
SpawnAgent { task: AgentTask },
LoadData { data_type, from_ref, to_ref },
GitStage(PathBuf), GitUnstage(PathBuf),
GitStageAll, GitUnstageAll,
SaveSettings,
RefreshGitStatus,
CopyToClipboard(String),
ExecuteCommit { message },
ExecuteAmend { message },
ShowNotification { level, message, duration_ms },
Redraw,
Quit,
GatherBlameAndSpawnAgent { file, start_line, end_line },
LoadFileLog(PathBuf),
LoadGlobalLog,
}Why return effects instead of executing directly?
- Testability — Test logic without I/O
- Traceability — Log all effects
- Batching — Combine multiple effects
- Ordering — Control execution order
Effect Execution
After reducer returns, StudioApp::execute_effects() runs:
fn execute_effects(&mut self, effects: Vec<SideEffect>) -> Option<ExitResult> {
for effect in effects {
match effect {
SideEffect::Quit => return Some(ExitResult::Quit),
SideEffect::ExecuteCommit { message } => {
return Some(self.perform_commit(&message));
}
SideEffect::ExecuteAmend { message } => {
return Some(self.perform_amend(&message));
}
SideEffect::SpawnAgent { task } => match task {
AgentTask::Commit { instructions, preset, use_gitmoji, amend } => {
self.spawn_commit_generation(instructions, preset, use_gitmoji, amend);
}
// ... other agent task variants
}
SideEffect::GitStage(path) => {
self.stage_file(&path.to_string_lossy());
}
// ... see src/studio/app/mod.rs for the full executor
}
}
None
}The executor returns Option<ExitResult> so the Quit, ExecuteCommit, and ExecuteAmend paths can short-circuit the event loop with a typed exit reason.
Notice: Effects can trigger new events, which feed back into the reducer.
State Structure
StudioState holds all application state:
pub struct StudioState {
// Repository & git
pub repo: Option<Arc<GitRepo>>,
pub git_status: GitStatus,
pub git_status_loading: bool,
// Configuration
pub config: Config,
// Navigation
pub active_mode: Mode,
pub focused_panel: PanelId,
// Mode-specific state
pub modes: ModeStates,
// Overlays
pub modal: Option<Modal>,
pub chat_state: ChatState,
// Notifications
pub notifications: VecDeque<Notification>,
// Agent status
pub iris_status: IrisStatus,
// Companion (ambient awareness)
pub companion: Option<CompanionService>,
pub companion_display: CompanionSessionDisplay,
// UI state
pub dirty: bool,
pub last_render: Instant,
}Mode-Specific State
Each mode has its own state struct:
pub struct ModeStates {
pub explore: ExploreState,
pub commit: CommitState,
pub review: ReviewState,
pub pr: PrState,
pub changelog: ChangelogState,
pub release_notes: ReleaseNotesState,
}
pub struct CommitState {
pub messages: Vec<GeneratedMessage>,
pub current_index: usize,
pub custom_instructions: String,
pub selected_file_index: usize,
pub editing_message: bool,
pub generating: bool,
pub use_gitmoji: bool,
pub emoji_mode: EmojiMode, // None | Auto | Custom(String)
pub preset: String,
pub file_tree: FileTreeState,
pub diff_view: DiffViewState,
pub message_editor: MessageEditorState,
pub show_all_files: bool,
pub amend_mode: bool,
pub original_message: Option<String>,
}Note the naming: each mode's state struct is *State, not *Mode. The container fields are explore, commit, review, pr, changelog, release_notes.
Why separate? Each mode has unique state. Keeps StudioState organized.
History Recording
The reducer records all significant events to History:
// Agent started
history.record_agent_start(TaskType::Commit);
// Agent completed
history.record_agent_complete(TaskType::Commit, /* success */ true);
// Mode switched
history.record_mode_switch(old_mode, new_mode);
// Content generated (one API for every content type)
history.record_content(
Mode::Commit,
ContentType::CommitMessage,
&ContentData::Commit(msg.clone()),
EventSource::Agent,
"generation_complete",
);
// Chat message
history.add_chat_message(ChatRole::User, "hi"); // simple form
history.add_chat_message_with_context( // with mode context
ChatRole::User,
"tighten this up",
state.active_mode,
Some(formatted_content),
);Why in reducer? Guarantees every state change is recorded, no missed events.
Common Reducer Patterns
Pattern 1: Simple State Update
StudioEvent::FocusNext => {
state.focus_next_panel();
// No effects needed
}Pattern 2: State Update + Effect
StudioEvent::RefreshGitStatus => {
state.set_iris_thinking("Refreshing git status...");
effects.push(SideEffect::RefreshGitStatus);
}Pattern 3: Conditional Logic
StudioEvent::StageFile(path) => {
if state.git_status.modified_files.contains(&path) {
effects.push(SideEffect::GitStage(path));
} else {
state.notify(Notification::warning("File not modified"));
}
}Pattern 4: Mode-Specific Behavior
StudioEvent::GenerateReview { from_ref, to_ref } => {
match state.active_mode {
Mode::Review => {
state.modes.review.generating = true;
effects.push(SideEffect::SpawnAgent {
task: AgentTask::Review { from_ref, to_ref },
});
}
_ => {
// Wrong mode, ignore or warn
}
}
}Pattern 5: Multi-Step Updates
StudioEvent::SwitchMode(new_mode) => {
let old_mode = state.active_mode;
// 1. Record to history
history.record_mode_switch(old_mode, new_mode);
// 2. Update state
state.switch_mode(new_mode);
// 3. Trigger data load for new mode
match new_mode {
Mode::Commit => {
effects.push(SideEffect::LoadData {
data_type: DataType::CommitDiff,
from_ref: None,
to_ref: None,
});
}
// ... other modes
}
}Async Event Loop
Problem: Reducer is synchronous, but LLM calls and git ops are async.
Solution: Effects spawn async tasks that send events back via channel:
Reducer (sync)
│
└──▶ Effect: SpawnAgent
│
└──▶ Executor (sync)
│
└──▶ tokio::spawn (async)
│
├──▶ Call LLM API
├──▶ Wait for response
└──▶ Send AgentComplete event via channel
│
└──▶ Event loop receives
│
└──▶ Back to reducerKey insight: Async work happens outside the reducer. Results come back as events.
Testing the Reducer
Pure functions are trivial to test:
#[test]
fn test_generate_commit_starts_agent() {
let mut state = test_state();
let mut history = History::new();
let event = StudioEvent::GenerateCommit {
instructions: None,
preset: "default".into(),
use_gitmoji: true,
amend: false,
};
let effects = reduce(&mut state, event, &mut history);
// Assert state changes
assert!(state.modes.commit.generating);
assert!(matches!(state.iris_status, IrisStatus::Thinking { .. }));
// Assert effects
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], SideEffect::SpawnAgent { .. }));
// Assert history captured the start
assert_eq!(history.event_count(), 1);
}No mocking, no async, no I/O. Just pure logic.
Debugging Tips
1. Log Every Event
pub fn reduce(state: &mut State, event: Event, history: &mut History) -> Vec<Effect> {
eprintln!("[REDUCE] {:?}", event);
// ... reducer logic ...
eprintln!("[EFFECTS] {:?}", effects);
effects
}2. Snapshot State Before/After
let before = format!("{:?}", state);
let effects = reduce(state, event, history);
let after = format!("{:?}", state);
eprintln!("BEFORE: {}\nAFTER: {}", before, after);3. Check History
// In test or at runtime - events() returns an iterator
for entry in history.events() {
println!("{:?} from {:?} at {:?}", entry.change, entry.source, entry.timestamp);
}4. Assert Effect Ordering
let effects = reduce(&mut state, event, &mut history);
assert!(effects[0].is_spawn_agent());
assert!(effects[1].is_load_data());Performance Considerations
Reducer is fast — Simple pattern matching and field assignments.
Avoid expensive operations in reducer:
- No network calls
- No file I/O
- No heavy computation
If you need to compute something expensive, return it as an effect:
// BAD: Expensive work in reducer
StudioEvent::AnalyzeCode => {
let analysis = expensive_analysis(&state); // Blocks reducer!
state.analysis = analysis;
}
// GOOD: Spawn async task
StudioEvent::AnalyzeCode => {
effects.push(SideEffect::SpawnAnalysis);
}Advanced Patterns
Optimistic Updates
Update state immediately, rollback if operation fails. The current StageFile flow is conservative — it returns a GitStage effect, then waits for FileStaged (or a notification on error) to update state — but an optimistic variant would look like this:
// Hypothetical optimistic version
StudioEvent::StageFile(path) => {
state.git_status.staged_files.push(path.clone());
state.mark_dirty();
effects.push(SideEffect::GitStage(path));
}
// On failure, the executor would push a follow-up event to roll back.Today's actual StageFile handler is simpler:
StudioEvent::StageFile(path) => {
effects.push(content::stage_file(path)); // returns SideEffect::GitStage
}Batched Effects
Combine multiple effects into one:
StudioEvent::RefreshAll => {
effects.push(SideEffect::RefreshGitStatus);
effects.push(SideEffect::LoadData { ... });
effects.push(SideEffect::LoadData { ... });
}Executor can batch git operations for efficiency.
Derived State
Compute state from other state (like React's useMemo):
// In state/mod.rs
impl StudioState {
pub fn has_uncommitted_changes(&self) -> bool {
self.git_status.staged_count > 0 ||
self.git_status.modified_count > 0
}
}Don't store derived state, compute on demand.
Common Pitfalls
❌ I/O in Reducer
// WRONG
StudioEvent::SaveSettings => {
self.config.save_to_file()?; // I/O!
}// RIGHT
StudioEvent::SaveSettings => {
effects.push(SideEffect::SaveSettings);
}❌ Async in Reducer
// WRONG
StudioEvent::GenerateCommit => {
let result = agent.generate().await?; // async!
state.message = result;
}// RIGHT
StudioEvent::GenerateCommit => {
state.generating = true;
effects.push(SideEffect::SpawnAgent { ... });
}❌ Side Effects Without Events
// WRONG - invisible state change
fn execute_effect(effect: Effect) {
match effect {
Effect::GitStage(path) => {
git_add(&path);
self.state.staged_files.push(path); // Hidden mutation!
}
}
}// RIGHT - emit event for reducer
fn execute_effect(effect: Effect) {
match effect {
Effect::GitStage(path) => {
git_add(&path);
self.push_event(StudioEvent::FileStaged(path)); // Explicit!
}
}
}Comparison to Other Patterns
Traditional MVC
Controller ──▶ Model (scattered mutations)
│
└──▶ View (reads model)Problems: Hard to trace, mutations everywhere.
MVVM / Data Binding
ViewModel ◀──▶ View (two-way binding)Problems: Complex dependency tracking, hard to debug.
Reducer Pattern
Event ──▶ Reducer ──▶ State ──▶ View
│
└──▶ Effects ──▶ Async ──▶ New EventsBenefits: Unidirectional flow, explicit state changes, traceable.
Further Reading
- Redux documentation (web equivalent)
- Elm architecture (original pattern)
- Flux architecture (Facebook's pattern)
Summary
Reducer = single source of truth for state transitions
- Pure function:
(state, event) → (state', effects) - All mutations in one place
- Side effects are explicit data
- Easy to test, trace, and debug
- Async work happens outside, results come back as events
When in doubt: If it's not a field assignment or simple logic, it should be an effect.
