Skip to Content

Selectors

Overview

Selectors (src/state/selectors.ts) are memoized functions that derive computed values from raw application state. They sit between the store and consumers, ensuring that expensive computations run only when their inputs change and that referentially equal outputs prevent unnecessary React re-renders.

Selectors follow the same pattern popularized by Redux’s Reselect library, but are implemented as a lightweight custom solution tailored to the AppStateStore mutation model.

Selector Architecture

Each selector checks whether its input values have changed since the last invocation. If inputs are identical (by reference), the previously computed result is returned immediately.

Why Selectors

Selectors solve two problems that are especially acute in a terminal UI rendered by Ink:

  1. Prevent redundant computation — Filtering, sorting, and aggregating large arrays (e.g., thousands of messages) on every render is expensive. Selectors ensure this work happens only when the source data actually changes.
  2. Ensure referential equality — React (and Ink) skip re-renders when props are referentially equal. Without memoization, a new array or object is created on every access, forcing re-renders even when the underlying data has not changed.

Selector Composition

Selectors compose naturally — base selectors feed into derived selectors, forming a dependency graph:

When selectMessages recomputes (because the raw messages array changed), selectActiveMessages and selectVisibleMessages also recompute — but selectPendingPermissions does not, because its input (selectPermissions) has not changed.

Common Selectors

SelectorInputOutput
selectActiveMessagesmessagesMessages not marked as hidden
selectPendingPermissionspermissionsPermission requests awaiting user approval
selectVisibleNotificationsnotificationsNon-dismissed, non-expired notifications
selectIsStreamingmessagesWhether any message is currently streaming
selectTaskProgresstasksAggregated completion percentage across tasks
selectAgentTreeagentsHierarchical tree of active sub-agents

Memoization Strategy

The createSelector utility implements a single-entry cache — it remembers only the most recent input and output:

function createSelector<TInput, TResult>( inputFn: (state: AppState) => TInput, computeFn: (input: TInput) => TResult ): (state: AppState) => TResult { let lastInput: TInput; let lastResult: TResult; return (state: AppState) => { const input = inputFn(state); if (!Object.is(input, lastInput)) { lastInput = input; lastResult = computeFn(input); } return lastResult; }; }

This approach works well because the store is a singleton — each selector is called with the same state reference in a given notification cycle, so a single cache entry is sufficient.

Selector Example

A typical selector filters and transforms raw state:

const selectActiveMessages = createSelector( (state) => state.messages, (messages) => messages.filter(m => !m.hidden) ); const selectVisibleMessages = createSelector( (state) => ({ active: selectActiveMessages(state), scroll: state.uiState.scrollPosition, }), ({ active, scroll }) => active.slice(scroll.start, scroll.end) );

selectVisibleMessages depends on selectActiveMessages. If messages have not changed, selectActiveMessages returns its cached array, and selectVisibleMessages also returns its cached result — zero computation, zero new object allocation.

Performance Impact

In Ink’s terminal rendering engine, every re-render repaints the entire terminal viewport. Selectors directly reduce the frequency of these repaints:

Without SelectorsWith Selectors
Every store notification triggers Array.filterFilter runs only when messages reference changes
New array on every call forces component re-renderSame reference returned, Ink skips repaint
O(n) per notification for each consuming componentO(1) for cache hit, O(n) only on actual change

Selectors rely on reference equality of their inputs. If a store mutation replaces an array with a new array of identical content, selectors will recompute. This is an intentional trade-off — deep equality checks would negate the performance benefit.

Design Patterns

  • Memoization — Single-entry cache keyed by input reference, avoiding redundant computation.
  • Selector Pattern — Inspired by Reselect, providing composable derived-state functions.
  • Derived State — Computed values that are kept consistent with their source data without manual synchronization.
Last updated on