Read OSS

Redux in a Terminal: State Management, Middleware, and the Effects Pattern

Advanced

Prerequisites

  • Article 2: IPC and Session Lifecycle
  • Redux middleware concepts
  • Immutable data structure patterns

Redux in a Terminal: State Management, Middleware, and the Effects Pattern

Using Redux to manage a terminal emulator's state is an unusual choice. Terminals are high-throughput systems where every keystroke and every line of output triggers state changes. The naive approach — dispatching actions through reducers and letting React re-render — would be catastrophically slow for terminal output.

Hyper solves this with a middleware chain that's unlike anything you'll see in a typical Redux application. The chain includes thunk appearing twice, a write middleware that short-circuits React entirely for the hottest code path, and a custom "effects" pattern that collocates side effects with actions while preserving plugin interception points.

Store Shape: Three Slices

The Redux store is assembled from three slices:

lib/reducers/index.ts

classDiagram
    class HyperState {
        +ui: uiState
        +sessions: sessionState
        +termGroups: ITermState
    }

    class uiState {
        +fontFamily: string
        +fontSize: number
        +colors: ColorMap
        +cursorColor: string
        +css: string
        +termCSS: string
        +backgroundColor: string
        +foregroundColor: string
        +maximized: boolean
        +fullScreen: boolean
        +bellSound: string
        +notifications: Notification[]
    }

    class sessionState {
        +sessions: Record~string, session~
        +activeUid: string | null
    }

    class ITermState {
        +termGroups: Record~string, ITermGroup~
        +activeSessions: Record~string, string~
        +activeRootGroup: string | null
    }

    HyperState --> uiState
    HyperState --> sessionState
    HyperState --> ITermState
  • ui — Everything visual: font settings, colors, cursor style, custom CSS, window state (maximized, fullscreen), bell configuration, and notification messages. This is the "theme" of the terminal.
  • sessions — Active terminal sessions keyed by UUID. Each session tracks its title, dimensions, cleared state, search state, and shell info.
  • termGroups — The split-pane tree structure. This is the most architecturally interesting slice and we'll examine it in depth below.

All three slices use seamless-immutable, a library that deeply freezes objects in development mode to catch accidental mutations. Each reducer is also wrapped by decorateReducer, which lets plugins extend state handling — but we'll cover that in Article 5.

The Middleware Chain Explained

The middleware pipeline is the heart of Hyper's Redux architecture:

lib/store/configure-store.dev.ts#L16

applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects)

Five middlewares, applied left-to-right. Let's trace why each one exists and why the order matters:

flowchart LR
    A["Action dispatched"] --> B["thunk₁"]
    B --> C["plugins.middleware"]
    C --> D["thunk₂"]
    D --> E["writeMiddleware"]
    E --> F["effects"]
    F --> G["Reducers"]

    style B fill:#e1f5fe
    style C fill:#fff3e0
    style D fill:#e1f5fe
    style E fill:#ffebee
    style F fill:#e8f5e9

thunk₁ — The first thunk handles Hyper's own thunked action creators. Actions like addSession and requestSession are functions that receive dispatch and getState. This thunk resolves them into plain action objects before they reach the plugin middleware.

plugins.middleware — Plugin-provided middleware runs here, after app thunks are resolved but before the action reaches the store. This is the interception point: plugins can modify, delay, or swallow any action. The middleware chains all plugin middlewares in sequence:

lib/utils/plugins.ts#L579-L583

thunk₂ — The second thunk exists because plugins can generate new thunked actions. When a plugin's middleware dispatches a function instead of a plain object, this second thunk resolves it. Without it, plugin-generated thunks would crash.

writeMiddleware — The performance-critical middleware. We'll examine it in detail below.

effects — Runs side-effect functions attached to actions after reducers have processed them.

Write Middleware: Bypassing React for Performance

This is the most important middleware in the chain:

lib/store/write-middleware.ts

const writeMiddleware: Middleware = () => (next) => (action) => {
  if (action.type === 'SESSION_PTY_DATA') {
    const term = terms[action.uid];
    if (term) {
      term.term.write(action.data);
    }
  }
  next(action);
};

When a SESSION_PTY_DATA action arrives (which happens for every batch of terminal output), the write middleware grabs the xterm.js Terminal instance directly from a global registry and writes data to it. This completely bypasses the React render cycle.

Think about what would happen without this middleware: terminal data would flow through reducers → store update → React reconciliation → component re-render → xterm.write(). That's at least 3 unnecessary steps for what is fundamentally an imperative operation. The write middleware turns it into: middleware → xterm.write(). Done.

The global terms registry is dead simple:

lib/terms.ts

const terms: Record<string, Term | null> = {};

Term components register themselves in this object during componentDidMount and unregister during componentWillUnmount. The write middleware is the primary consumer.

flowchart TD
    A["SESSION_PTY_DATA action"] --> B["writeMiddleware"]
    B --> C{"terms[uid] exists?"}
    C -->|Yes| D["term.term.write(data)"]
    C -->|No| E[Skip]
    D --> F["next(action)"]
    E --> F
    F --> G["Reducers update lastActivity timestamp"]
    G --> H["React re-renders tab title, NOT terminal content"]

Tip: The action still passes through to the reducers via next(action). The session reducer updates the lastActivity timestamp when it sees SESSION_PTY_DATA, which is used to show which tab has recent activity. So React does re-render — but only the tab indicator, never the terminal canvas.

The Effects Pattern

Hyper's effects middleware provides a simple but powerful pattern for collocating side effects with actions:

lib/utils/effects.ts

const effectsMiddleware: Middleware = () => (next) => (action) => {
  const ret = next(action);
  if (action.effect) {
    action.effect();
    delete action.effect;
  }
  return ret;
};

The middleware runs after next(action), meaning the reducers have already processed the action. Then it checks for an effect() function on the action object and executes it. Here's a real example from session actions:

lib/actions/sessions.ts#L40-L51

export function requestSession(profile) {
  return (dispatch, getState) => {
    dispatch({
      type: SESSION_REQUEST,
      effect: () => {
        const {ui} = getState();
        const {cwd} = ui;
        rpc.emit('new', {cwd, profile});
      }
    });
  };
}

Why not just call rpc.emit directly in the thunk? Because of plugins. The effects middleware sits after the plugin middleware in the chain. If a plugin intercepts SESSION_REQUEST and prevents it from reaching the reducers (by not calling next()), the effect never executes either. This gives plugins a clean way to intercept both state changes and their associated side effects.

The addSessionData action at lib/actions/sessions.ts#L53-L69 shows a nested dispatch pattern: the effect of SESSION_ADD_DATA dispatches SESSION_PTY_DATA, which is the action that triggers the write middleware. This two-step dispatch ensures plugins can intercept the data flow at two levels — before metadata is recorded and before the terminal is written to.

Term Groups: Immutable Split Pane Tree

The termGroups reducer models Hyper's split-pane UI as a tree structure using seamless-immutable:

lib/reducers/term-groups.ts#L13-L29

Each ITermGroup node has:

  • uid — Unique identifier
  • sessionUid — If this is a leaf node, the terminal session it contains (null for parent nodes)
  • parentUid — Parent node reference (null for root)
  • direction — Split direction: 'HORIZONTAL' or 'VERTICAL' (null for leaves)
  • sizes — Array of proportional sizes for children (null means equal sizing)
  • children — Array of child group UIDs
graph TD
    R["Root Group<br/>direction: VERTICAL<br/>sizes: [0.5, 0.5]"]
    L["Left Group<br/>sessionUid: 'abc123'"]
    P["Right Parent<br/>direction: HORIZONTAL<br/>sizes: [0.5, 0.5]"]
    T["Top Group<br/>sessionUid: 'def456'"]
    B["Bottom Group<br/>sessionUid: 'ghi789'"]

    R --> L
    R --> P
    P --> T
    P --> B

The splitGroup function handles the tree transformation when a user splits a pane. The algorithm decides whether to add a sibling to the existing parent or create a new parent node, depending on whether the split direction matches the parent's direction.

Size rebalancing is proportional. When inserting, insertRebalance distributes the new pane's space proportionally — larger panes give up more absolute space. When removing, removalRebalance spreads the freed space equally among remaining siblings.

The replaceParent helper collapses one-child parents. When closing a split leaves a parent with a single child, the parent is removed and the child takes its place in the tree. This prevents unnecessary nesting.

RPC-to-Redux Dispatch Wiring

The bridge between IPC events and Redux state changes lives in lib/index.tsx#L72-L234. Roughly 30 RPC event handlers each dispatch a corresponding Redux action:

flowchart LR
    subgraph "RPC Events (from Main)"
        A["'session data'"]
        B["'session add'"]
        C["'session exit'"]
        D["'split request horizontal'"]
        E["'increase fontSize req'"]
        F["'move left req'"]
    end

    subgraph "Redux Actions"
        G["SESSION_PTY_DATA"]
        H["SESSION_ADD"]
        I["TERM_GROUP_EXIT"]
        J["REQUEST_HORIZONTAL_SPLIT"]
        K["UI_FONT_SIZE_INCREASE"]
        L["UI_MOVE_LEFT"]
    end

    A --> G
    B --> H
    C --> I
    D --> J
    E --> K
    F --> L

The 'session data' handler is worth noting for its optimization: it extracts the UUID from the first 36 characters of the string (remember the DataBatcher's UID prepending from Article 2) before dispatching:

rpc.on('session data', (d: string) => {
  const uid = d.slice(0, 36);
  const data = d.slice(36);
  store_.dispatch(sessionActions.addSessionData(uid, data));
});

This is a zero-copy parse — no JSON deserialization, no object allocation for the common case.

What's Next

We've seen how data flows through Redux and how the middleware chain keeps terminal rendering fast. But the Redux store only holds state — something needs to turn that state into pixels. In the next article, we'll examine how Hyper wraps xterm.js in React components, handles WebGL/Canvas renderer selection with automatic fallback, and coordinates a two-tier keyboard system between Mousetrap and xterm's own key handling.