Skip to content
/ gpui-ts Public

A JavaScript library inspired by gpui and solid-events

License

Notifications You must be signed in to change notification settings

doeixd/gpui-ts

Repository files navigation

Ask DeepWiki

GPUI-TS

A type-safe reactive state management framework inspired by GPUI and solid-events. GPUI-TS provides centralized model ownership, functional reactive event composition, and declarative rendering with lit-html integration.

Table of Contents

Why GPUI-TS?

Modern web applications struggle with state management complexity. Traditional approaches lead to:

  • State scattered across components - Hard to track where mutations come from
  • Manual synchronization - Forgetting to update UI when data changes
  • Type safety gaps - Runtime errors from incorrect state access
  • Performance issues - Unnecessary re-renders and memory leaks

GPUI-TS solves these problems with:

// Define your entire app state structure with full type inference
const AppSchema = createSchema()
  .model('todos', { items: [] as Array<{ id: number; text: string }> })
  .model('ui', { newTodoText: '', editingId: null })
  .build()

const app = createApp(AppSchema)

// All state mutations are declarative and type-safe
const [onAddTodo, addTodo] = createModelEvent(
  app.models.todos,
  (text: string, state) => {
    if (text.trim().length > 0) {
      state.items.push({ id: Date.now(), text })
    }
  }
)

// Reactive subjects auto-sync with model state
const todoCount = createModelSubject(app.models.todos, s => s.items.length)

// Views automatically re-render when state changes
createView(app.models.todos, container, (state, ctx) => html`
  <div>
    <h2>Todos (${todoCount()})</h2>
    <input
      .value=${ctx.bind('newTodoText').value}
      @input=${ctx.bind('newTodoText').onChange}
      @keydown=${(e: KeyboardEvent) => {
        if (e.key === 'Enter') {
          addTodo(app.models.ui.read().newTodoText)
          ctx.updateAt('newTodoText', () => '')
        }
      }}
    />
    <ul>
      ${state.items.map(todo => html`<li>${todo.text}</li>`)}
    </ul>
  </div>
`)

Quick Start

Installation

npm install gpui-ts lit-html

Basic Example

import { createSchema, createApp } from 'gpui-ts'
import { createView, html } from 'gpui-ts/lit-html'

// 1. Define your app schema (full type inference)
const CounterSchema = createSchema()
  .model('counter', { count: 0 })
  .build()

// 2. Create the app
const app = createApp(CounterSchema)

// 3. Create reactive view
createView(app.models.counter, document.body, (state, ctx) => html`
  <div>
    <h1>Count: ${state.count}</h1>
    <button @click=${() => ctx.updateAt('count', c => c + 1)}>
      Increment
    </button>
  </div>
`)

Event-Driven Example

// Create events with transformation chains
const [onIncrement, emitIncrement] = createEvent<number>()
const [onReset, emitReset] = createEvent<void>()

// Transform and validate events
const validIncrement = onIncrement.filter(delta => delta > 0)

// Create reactive subjects that respond to events
const counter = createSubject(
  0,
  validIncrement(delta => count => count + delta),
  onReset(() => 0)
)

// Wire up UI
document.getElementById('increment').onclick = () => emitIncrement(1)
document.getElementById('reset').onclick = () => emitReset()

Core Concepts

Models: Centralized State Ownership

Unlike Redux or Zustand, GPUI-TS models own their state completely. Updates go through a controlled API:

// ❌ Direct mutation (impossible in GPUI-TS)
state.user.name = 'John'

// ✅ Controlled updates with context
app.models.user.update((state, ctx) => {
  state.name = 'John'
  ctx.notify() // Triggers reactive updates
  ctx.emit({ type: 'nameChanged', name: 'John' })
})

// ✅ Path-based updates with type safety
app.models.user.updateAt('profile.name', name => name.toUpperCase())

// ✅ Conditional updates with type guards
app.models.user.updateIf(
  (state): state is LoggedInUser => state.isLoggedIn,
  (user, ctx) => {
    // TypeScript knows user.profile exists here
    user.profile.lastSeen = new Date()
  }
)

// ✅ Convenience methods for common patterns
app.models.counter.updateAndNotify(state => state.count++) // Automatic notification
app.models.ui.set('theme', 'dark') // Direct value assignment
app.models.settings.toggle('notifications') // Boolean toggle
app.models.todos.push('items', newTodo) // Add to array
app.models.todos.removeWhere('items', item => item.completed) // Remove from array
app.models.form.reset() // Reset to initial state

Ergonomic Proxy API: Direct-Style Mutations

For simple state updates, GPUI-TS provides an optional proxy API that feels like direct object manipulation while maintaining all the safety guarantees:

// Get a proxy for ergonomic updates
const userProxy = app.models.user.asProxy()

// Direct-style assignments (syntactic sugar over .set())
userProxy.name = 'Jane'
userProxy.profile.age = 31
userProxy.settings.theme = 'dark'

// Works with nested objects and arrays
const todosProxy = app.models.todos.asProxy()
todosProxy.items.push({ id: 3, text: 'New task', completed: false })
todosProxy.items[0].completed = true

// Mix and match with explicit API
userProxy.name = 'Quick Change'
app.models.user.update(state => {
  if (state.canBeUpdated) {
    state.lastUpdated = new Date()
    state.version++
  }
})

Key Features:

  • 100% Type-Safe: Full TypeScript inference for nested properties
  • Backwards Compatible: Proxy is optional, uses the same update mechanism under the hood
  • Automatic Notifications: Changes trigger reactive updates just like explicit API calls
  • Cached Proxies: Same proxy instance returned for the same path for consistency
  • Array Methods: Native array operations (push, pop, splice, etc.) work seamlessly
  • Batch Updates: Use .batch() to group multiple proxy mutations into a single notification
// All of these are equivalent:
userProxy.profile.name = 'Jane'
app.models.user.set('profile.name', 'Jane')
app.models.user.update(state => { state.profile.name = 'Jane' })

// Batch multiple proxy updates for performance
app.models.user.batch(() => {
  userProxy.name = 'Jane'
  userProxy.age = 30
  userProxy.city = 'NYC'
}) // Single notification after all updates complete

Events: Functional Reactive Composition

Events support transformation chains inspired by solid-events:

const [onUserInput, emitUserInput] = createEvent<string>()

// Chain transformations with automatic type inference
const validInput = onUserInput
  .filter(text => text.length > 0)          // Remove empty strings
  .map(text => text.trim().toLowerCase())   // Normalize
  .debounce(300)                           // Reduce noise

// Multiple events can feed into subjects
const searchResults = createSubject(
  [],
  validInput(query => () => searchAPI(query)),
  onClearSearch(() => [])
)

Subjects: Reactive State Derivation

Subjects automatically update when their dependencies change:

// Subjects respond to multiple event sources
const todoStats = createSubject(
  { total: 0, completed: 0, active: 0 },
  onTodoAdded(() => stats => ({
    ...stats,
    total: stats.total + 1,
    active: stats.active + 1
  })),
  onTodoToggled(({ completed }) => stats => ({
    ...stats,
    completed: completed ? stats.completed + 1 : stats.completed - 1,
    active: completed ? stats.active - 1 : stats.active + 1
  }))
)

Lenses: Composable Data Access

Lenses provide functional, immutable access to nested data structures with full type safety:

// Basic lens creation
const nameLens = lens(
  (user) => user.name,
  (user, name) => ({ ...user, name })
)

// Lens composition
const profileLens = lens(
  (user) => user.profile,
  (user, profile) => ({ ...user, profile })
)
const nameLens = lens(
  (profile) => profile.name,
  (profile, name) => ({ ...profile, name })
)
const userNameLens = profileLens.compose(nameLens)

// Using at() for property access
const emailLens = userLens.at('profile').at('email')

// Array operations
const itemsLens = lens(
  (state) => state.items,
  (state, items) => ({ ...state, items })
)
const firstItemLens = itemsLens.index(0)
const activeItemsLens = itemsLens.filter(item => item.active)
const firstActiveLens = itemsLens.find(item => item.active)

// Advanced operations
const namesLens = itemsLens.map(item => item.name) // Read-only
const hasActiveLens = itemsLens.some(item => item.active) // Read-only
const totalValueLens = itemsLens.reduce((sum, item) => sum + item.value, 0) // Read-only

// Model integration
const userName = app.models.user.lensAt('profile.name')
app.models.user.update((state) => {
  const newState = userName.set(state, 'Jane')
  Object.assign(state, newState)
})

// Focused models
const profileFocus = app.models.user.focus(profileLens)
profileFocus.update(profile => {
  profile.name = 'Jane'
  profile.age = 31
})

Model-Scoped Events: Type-Safe Event Handling

GPUI-TS supports both global events and model-scoped events with full type safety:

const AppSchema = createSchema()
  .model('counter', { count: 0 })
    .events({
      incremented: (amount: number) => ({ amount }),
      reset: () => ({})
    })
  .model('user', { name: '' })
  .events({
    login: { payload: { email: string } },
    logout: { payload: {} }
  })
  .build()

const app = createApp(AppSchema)

// Model-scoped events with typed emit/on namespaces
app.models.counter.emit.incremented(5)  // Type-safe payload
app.models.counter.on.incremented(amount => {
  console.log(`Counter incremented by ${amount}`)
})

// Emit namespace is also callable for ad-hoc events
app.models.counter.emit({ type: 'custom', data: 'value' })

// Global events within update context
app.models.user.update((state, ctx) => {
  ctx.emit({ type: 'login', payload: { email: '[email protected]' } })
})

Memoized Selectors: Efficient Derived State

Create memoized selectors for computed values with automatic dependency tracking and configurable caching:

import { createSelector, createModelSelector, shallowEqual } from 'gpui-ts'

// Basic selector with deep equality memoization (default)
const todoStatsSelector = createSelector(
  [(state: TodoState) => state.todos, (state: TodoState) => state.filter],
  (todos, filter) => ({
    total: todos.length,
    completed: todos.filter(t => t.completed).length,
    active: todos.filter(t => !t.completed).length,
    visible: todos.filter(t => {
      if (filter === 'completed') return t.completed
      if (filter === 'active') return !t.completed
      return true
    })
  })
)

// Shallow equality for better performance with large arrays
const itemCountSelector = createSelector(
  [(state) => state.items],
  (items) => items.length,
  { equalityFn: shallowEqual }
)

// LRU cache for selectors with varying inputs (e.g., pagination)
const userDataSelector = createSelector(
  [(state) => state.currentUserId],
  (userId) => expensiveUserComputation(userId),
  {
    cacheStrategy: 'lru',
    maxCacheSize: 10 // Keep last 10 users in cache
  }
)

// FIFO cache for time-series or streaming data
const recentEventsSelector = createSelector(
  [(state) => state.eventId],
  (eventId) => fetchEventData(eventId),
  {
    cacheStrategy: 'fifo',
    maxCacheSize: 20
  }
)

// Model-specific selectors
const todoStats = createModelSelector('todos', state => state.items)

// Usage in views
createView(app.models.todos, container, (state, ctx) => {
  const stats = todoStatsSelector(app)
  return html`
    <div>
      <p>Total: ${stats.total}</p>
      <p>Completed: ${stats.completed}</p>
      <p>Active: ${stats.active}</p>
      <ul>
        ${stats.visible.map(todo => html`<li>${todo.text}</li>`)}
      </ul>
    </div>
  `
})

Selector Options:

  • equalityFn: Custom equality function (deepEqual [default], shallowEqual, or custom)
  • cacheStrategy: 'unbounded' (default), 'lru', or 'fifo'
  • maxCacheSize: Maximum cache entries for LRU/FIFO strategies (default: 1)

When to use each strategy:

  • Unbounded (default): Simple selectors with consistent inputs
  • Shallow equality: Large arrays/objects where reference equality is sufficient
  • LRU cache: Dynamic inputs (pagination, user selection, search queries)
  • FIFO cache: Streaming data or time-series where old values become irrelevant

Event Composition: Advanced Reactive Patterns

GPUI-TS supports sophisticated event composition patterns inspired by solid-events:

import { createEvent, createTopic, createPartition, createSubject } from 'gpui-ts'

// Event transformation chains
const [onUserInput, emitUserInput] = createEvent<string>()

const validInput = onUserInput
  .filter(text => text.length > 3)           // Only process longer inputs
  .map(text => text.trim().toLowerCase())    // Normalize
  .debounce(300)                            // Rate limiting

// Event topics - merge multiple sources
const [onMouseMove, emitMouseMove] = createEvent<{x: number, y: number}>()
const [onTouchMove, emitTouchMove] = createEvent<{x: number, y: number}>()

const allMoves = createTopic([onMouseMove, onTouchMove])

// Event partitions - conditional splitting
const [clicks, drags] = createPartition(
  allMoves,
  event => Math.abs(event.x - startX) > 10 ? 1 : 0  // 1 = drag, 0 = click
)

// Reactive subjects with event reactions
const dragState = createSubject(
  { isDragging: false, startX: 0, startY: 0 },
  onMouseDown(({x, y}) => () => ({ isDragging: true, startX: x, startY: y })),
  drags(event => state => ({ ...state, currentX: event.x, currentY: event.y })),
  onMouseUp(() => () => ({ isDragging: false }))
)

Dynamic Event Management

Add events to running applications at runtime:

let app = createApp(createSchema()
  .model('user', { name: '' })
  .build()
)

// Add events dynamically with full type safety
app = addEvent(app, 'userCreated', {
  payload: { id: string, name: string }
})

// The app now has the new event type
app.models.user.update((state, ctx) => {
  ctx.emit({ type: 'userCreated', payload: { id: '123', name: state.name } })
})

Schemas: Type-Safe App Definition

Schemas drive complete type inference:

const BlogSchema = createSchema()
  .model('posts', {
    items: [] as Post[],
    loading: false,
    selectedId: null as string | null
  })
    .events({
      postAdded: (post: Post) => ({ post }),
      postDeleted: (id: string) => ({ id })
    })
  .model('user', {
    profile: null as UserProfile | null,
    preferences: { theme: 'light', notifications: true }
  })
  .events({
    postSelected: { payload: { id: string } },
    themeChanged: { payload: { theme: 'light' | 'dark' } }
  })
  .plugin(authPlugin)  // Add authentication state
  .build()

// TypeScript infers everything:
// app.models.posts.emit.postAdded(newPost) // Type-safe event emission
// app.models.posts.on.postAdded(({post}) => console.log(post)) // Type-safe event handling
// app.models.posts.updateAt('items.0.title', title => ...)
// app.models.user.readAt('preferences.theme') // 'light' | 'dark'

Dynamic Schema Management

GPUI-TS supports dynamic schema modifications at runtime and build time, enabling advanced patterns like code-splitting, plugins, and modular architectures.

Runtime Schema Modification

For applications that need to add or remove features dynamically (e.g., code-splitting, plugins):

import { createApp, addModel, removeModel, addEvent } from 'gpui-ts'

let app = createApp(createSchema()
  .model('user', { name: '' })
  .build()
)

// Add a new model dynamically
app = addModel(app, 'posts', {
  initialState: { items: [], loading: false }
})

// The app is now fully typed with the new model
app.models.posts.update(state => {
  state.loading = true
})

// Add events dynamically
app = addEvent(app, 'postCreated', {
  payload: { title: string }
})

// Remove models when features are unloaded
app = removeModel(app, 'posts')
// TypeScript now knows posts is gone

Build-Time Schema Composition

For composing schemas from multiple modules before app creation:

import { createSchema, addModelToSchema, removeModelFromSchema, addEventToSchema } from 'gpui-ts/helpers'

// Feature modules can contribute to schema
export function withAuth(builder) {
  let newBuilder = addModelToSchema(builder, 'auth', { user: null })
  return addEventToSchema(newBuilder, 'login', { payload: { email: '' } })
}

export function withTodos(builder) {
  return addModelToSchema(builder, 'todos', { items: [] })
}

// Compose in main app
let schemaBuilder = createSchema()
  .model('ui', { theme: 'dark' })

schemaBuilder = withAuth(schemaBuilder)
schemaBuilder = withTodos(schemaBuilder)

const app = createApp(schemaBuilder.build())
// app.models is fully typed with ui, auth, and todos

API Reference

Core Functions

createApp<TSchema>(schema: TSchema)

Creates a GPUI application with full type inference from schema.

const app = createApp(MySchema)
// app.models.* are fully typed based on schema

createSchema()

Fluent builder for app schemas:

const schema = createSchema()
  .model('todos', { items: [] })
  .events({ todoAdded: { payload: { text: string } } })
  .plugin(uiStatePlugin)
  .build()

createEvent<T>()

Creates event handler and emitter with transformation support:

const [onEvent, emitEvent] = createEvent<PayloadType>()

// Transform with solid-events style chaining
const transformed = onEvent
  .filter(payload => isValid(payload))
  .map(payload => normalize(payload))

createSubject<T>(initialValue, ...eventHandlers)

Creates reactive state that responds to events:

const count = createSubject(
  0,
  onIncrement(delta => current => current + delta),
  onReset(() => 0)
)

addModel<TApp, TModelName, TState>(app, modelName, modelDefinition)

Dynamically adds a new model to a running GPUI application:

let app = createApp(MySchema)
app = addModel(app, 'posts', {
  initialState: { items: [], loading: false }
})
// app.models.posts is now available and fully typed

removeModel<TApp, TModelName>(app, modelName)

Removes a model from a running GPUI application and cleans up resources:

app = removeModel(app, 'posts')
// app.models.posts is now undefined and TypeScript knows it's gone

addEvent<TApp, TEventName, TPayload>(app, eventName, payloadDef)

Adds a new event definition to the application schema:

app = addEvent(app, 'postCreated', {
  payload: { title: string, content: string }
})

addModelToSchema<TBuilder, TModelName, TState>(builder, modelName, initialState)

Build-time helper for adding models to schema builders:

let builder = createSchema().model('user', { name: '' })
builder = addModelToSchema(builder, 'posts', { items: [] })

removeModelFromSchema<TBuilder, TModelName>(builder, modelName)

Build-time helper for removing models from schema builders:

builder = removeModelFromSchema(builder, 'posts')

addEventToSchema<TBuilder, TEventName, TPayload>(builder, eventName, payloadDef)

Build-time helper for adding events to schema builders:

builder = addEventToSchema(builder, 'login', { payload: { email: '' } })

Model API

interface ModelAPI<T> {
  // State access
  read(): T
  readAt<P extends Path<T>>(path: P): PathValue<T, P>

  // Updates
  update(updater: (state: T, ctx: ModelContext<T>) => void): this
  updateAt<P extends Path<T>>(path: P, updater: (value: PathValue<T, P>) => PathValue<T, P>): this
  updateIf<TGuard extends T>(guard: (state: T) => state is TGuard, updater: (state: TGuard, ctx: ModelContext<T>) => void): this
  updateAndNotify(updater: (state: T) => void, onError?: (error: unknown, initialState: DeepReadonly<T>) => void): this

  // Helper methods for common state manipulations
  set<P extends Path<T>>(path: P, value: PathValue<T, P>): this
  toggle<P extends Path<T>>(path: PathValue<T, P> extends boolean ? P : never): this
  reset(): this
  push<P extends Path<T>>(path: P, ...items: PathValue<T, P> extends (infer U)[] ? U[] : never): this
  removeWhere<P extends Path<T>>(path: P, predicate: (item: PathValue<T, P> extends (infer U)[] ? U : never) => boolean): this
  updateAsync<LoadingKey extends keyof T, ErrorKey extends keyof T>(
    updater: (state: T) => Promise<Partial<T>>,
    options: {
      loadingKey: PathValue<T, LoadingKey> extends boolean ? LoadingKey : never
      errorKey: ErrorKey
      onError?: (error: unknown, initialState: DeepReadonly<T>) => void
    }
  ): Promise<void>

  // Events
  emit: {
    // Model-scoped typed event emission namespace
    // e.g., model.emit.incremented(5) for events defined in schema
    // Also callable as function for ad-hoc events: model.emit({ type: 'custom', data })
  }
  on: {
    // Model-scoped typed event subscription namespace
    // e.g., model.on.incremented(amount => console.log(amount)) for events defined in schema
  }
  emitEvent<TEvent>(event: TEvent): this
  onEvent<TEvent>(handler: (event: TEvent) => void): () => void

  // Subscriptions
  onChange(listener: (current: T, previous: T) => void): () => void
  subscribeTo<TSource>(source: ModelAPI<TSource>, reaction: (source: TSource, target: T, ctx: ModelContext<T>) => void): ModelSubscription

  // Advanced
  lens<TFocus>(getter: (state: T) => TFocus): Lens<T, TFocus>
  focus<TFocus>(lens: Lens<T, TFocus>): FocusedModel<TFocus, T>
  transaction<TResult>(work: (ctx: ModelContext<T>) => TResult): TResult
  snapshot(): ModelSnapshot<T>
  validate(): ValidationResult<T>
}

createSelector<TInput, TResult>(...inputSelectors, combiner, options?)

Creates a memoized selector function with deep equality checking for optimal performance:

// Basic selector with default deep equality
const userDisplayName = createSelector(
  [(state: UserState) => state.firstName, (state: UserState) => state.lastName],
  (firstName, lastName) => `${firstName} ${lastName}`.trim()
)

const displayName = userDisplayName(userState) // Memoized computation

// With custom options
const cachedSelector = createSelector(
  [(state) => state.userId],
  (userId) => expensiveComputation(userId),
  {
    cacheStrategy: 'lru',  // or 'fifo' or 'unbounded' (default)
    maxCacheSize: 10,      // Keep 10 most recent results
    equalityFn: shallowEqual  // or deepEqual (default) or custom function
  }
)

createModelSelector<TApp, TModelName, TResult>(model, selector)

Creates a model-specific selector that automatically provides the model's current state:

const userFullName = createModelSelector(app.models.user, userDisplayName)
const name = userFullName() // Automatically uses current user model state

createTopic<TEvent>(eventSources)

Merges multiple event sources into a single event stream:

const [onMouseMove, emitMouseMove] = createEvent<{x: number, y: number}>()
const [onTouchMove, emitTouchMove] = createEvent<{x: number, y: number}>()

const allPointerMoves = createTopic([onMouseMove, onTouchMove])
allPointerMoves.subscribe(event => console.log('Pointer moved:', event))

createPartition<TEvent>(sourceEvent, partitioner)

Splits events into multiple streams based on a partitioning function:

const [validInputs, invalidInputs] = createPartition(
  onUserInput,
  input => input.length >= 3 ? 0 : 1  // 0 = valid stream, 1 = invalid stream
)

validInputs.subscribe(input => processValidInput(input))
invalidInputs.subscribe(input => showValidationError(input))

Lit-HTML Integration

// Create reactive views
createView(model, container, (state, ctx) => html`
  <input .value=${ctx.bind('text').value} @input=${ctx.bind('text').onChange} />
  <button @click=${() => ctx.emit(submitEvent({ text: state.text }))}>
    Submit
  </button>
`)

// Component-style views
const MyComponent = createComponent<{name: string}, {count: number}>((props) => ({
  state: createSubject({ count: 0 }),
  template: (state, ctx) => html`
    <div>${props.name}: ${state.count}</div>
    <button @click=${() => ctx.updateAt('count', c => c + 1)}>+</button>
  `
}))

Modules Overview

GPUI-TS is organized into several focused modules, each providing specific functionality:

Core Module (src/index.ts)

The foundation of GPUI-TS, providing the core state management engine.

Key Exports:

  • createApp() - Creates the main application instance
  • createSchema() - Fluent schema builder
  • createEvent() - Event system with transformation chains
  • createSubject() - Reactive state containers
  • ModelAPI - Complete model interface with all state operations
  • ModelRegistry - Central state management and effect queuing
  • Lens - Composable data access and updates
  • CRDTManager - Conflict-free replicated data types support
  • createReducer() - Reducer-based state management
  • Dynamic schema modification functions (addModel, removeModel, addEvent)

Features:

  • Centralized model ownership with queued effects
  • Functional reactive event composition
  • Advanced type inference and path manipulation
  • Transaction support with rollback
  • Time travel debugging
  • Comprehensive validation

Lit-HTML Integration (src/lit.ts)

Seamless integration with lit-html for reactive rendering.

Key Exports:

  • createView() - Reactive view binding to models
  • createComponent() - Component-style view composition
  • bind() - Form input binding directive
  • when() - Conditional rendering directive
  • forEach() - List rendering directive
  • asyncTemplate() - Async operation rendering
  • suspense() - Loading/error/success state rendering
  • devView() - Development mode debugging
  • performanceView() - Performance monitoring

Features:

  • Automatic re-rendering on state changes
  • Type-safe template functions
  • Automatic cleanup and lifecycle management
  • Performance optimized rendering
  • Development mode debugging

Schema Helpers (src/helpers.ts)

Type-safe utilities for building and manipulating schemas.

Key Exports:

  • createSchema() - Fluent schema builder
  • createModelSchema() - Advanced model schema configuration
  • mergeSchemas() - Schema composition
  • validators - Built-in validation rules
  • combineValidators() - Validation composition
  • validateSchema() - Schema validation
  • introspectSchema() - Schema analysis
  • generateTypes() - TypeScript type generation
  • Standalone composition helpers (addModelToSchema, etc.)

Features:

  • Fluent API for schema definition
  • Schema composition and merging
  • Type-safe model extensions
  • Validation and constraints helpers
  • Plugin system for schema augmentation
  • Development utilities

Advanced Features (src/advanced.ts)

Powerful patterns for complex state management scenarios.

Key Exports:

  • createReactiveView() - Fine-grained reactivity with signals
  • createResource() - Formalized async state management
  • createMachineModel() - XState integration
  • Signal - Reactive primitive for fine-grained updates
  • Computed - Derived reactive values

Features:

  • Signal-based reactivity for optimal performance
  • Declarative async data fetching with loading states
  • State machine integration with XState
  • Automatic race condition handling
  • Fine-grained DOM updates

Ergonomic Context API (src/ergonomic.ts)

Composition API-style interface using unctx for cleaner setup code.

Key Exports:

  • createAppWithContext() - Context-aware app creation
  • useApp() - Access active application instance
  • useModel() - Direct model access by name
  • useResource() - Context-aware resource creation
  • useMachineModel() - Context-aware state machine integration
  • useSignalFromModel() - Bridge models to signals

Features:

  • Global context management with unctx
  • Ergonomic hooks for common operations
  • Type-safe model access without prop drilling
  • Cleaner, more modular setup code
  • Async-safe context usage patterns

Additional Modules

Signals (src/signals.ts):

  • Reactive primitives for fine-grained reactivity
  • Integration with GPUI-TS models
  • Signal-based view updates

Resources (src/resource.ts):

  • Specialized async state management
  • Loading, error, and success state handling
  • Automatic dependency tracking

Infinite Resources (src/infinite-resource.ts):

  • Pagination and infinite scrolling support
  • Virtual scrolling integration
  • Memory-efficient large dataset handling

CRDT (src/crdt.ts):

  • Conflict-free replicated data types
  • Collaborative editing support
  • Operation broadcasting and conflict resolution

Robot (src/robot.ts):

  • State machine and robot pattern implementations
  • Complex workflow management
  • Hierarchical state handling

Each module is designed to be used independently or in combination, providing a flexible and scalable architecture for building complex applications with type safety and excellent developer experience.

Comparison with Other Frameworks

vs Redux/RTK

Feature GPUI-TS Redux/RTK
Boilerplate Minimal with schema inference High (actions, reducers, selectors)
Type Safety Complete compile-time safety Requires manual typing
Learning Curve Moderate (new concepts) High (many concepts)
Performance Automatic batching, fine-grained updates Requires React.memo optimization
Side Effects Built-in effect system Requires middleware (thunks, sagas)
// Redux: Multiple files, lots of boilerplate
const ADD_TODO = 'ADD_TODO'
interface AddTodoAction { type: typeof ADD_TODO; payload: { text: string } }
const addTodo = (text: string): AddTodoAction => ({ type: ADD_TODO, payload: { text } })
const todosReducer = (state = [], action: AnyAction) => { /* ... */ }

// GPUI-TS: Single declaration, full type inference
const TodoSchema = createSchema()
  .model('todos', { items: [] as Todo[] })
  .build()

vs Zustand

Feature GPUI-TS Zustand
State Updates Centralized with controlled mutations Direct mutations in stores
Reactivity Automatic reactive subscriptions Manual selector-based subscriptions
Event System First-class events with composition No built-in event system
Validation Built-in schema validation Manual validation
// Zustand: Manual subscriptions
const useTodoStore = create((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({ todos: [...state.todos, { text }] }))
}))

// GPUI-TS: Reactive subjects
const todos = createSubject([], onTodoAdded(text => todos => [...todos, { text }]))

vs MobX

Feature GPUI-TS MobX
Predictability Explicit updates through controlled API Implicit updates via proxies
Debugging Clear update paths, time travel Can be hard to track mutation sources
Type Safety Full TypeScript integration Good but requires decorators/setup
Framework Coupling Framework agnostic Tight React coupling

vs React Hooks

Feature GPUI-TS React Hooks
State Sharing Global reactive models Props drilling or Context
Derived State Automatic with subjects Manual with useMemo
Side Effects Built-in effect system useEffect dependencies
Testing Framework-independent models Component testing complexity
// React: Complex dependency management
const [todos, setTodos] = useState([])
const [filter, setFilter] = useState('all')
const filteredTodos = useMemo(() => 
  todos.filter(todo => filter === 'all' || todo.status === filter),
  [todos, filter]
)

// GPUI-TS: Automatic reactive derivation
const filteredTodos = createSubject(
  [],
  onTodosChanged(todos => () => filterTodos(todos, currentFilter())),
  onFilterChanged(filter => () => filterTodos(currentTodos(), filter))
)

Common Patterns

Form Handling

const FormSchema = createSchema()
  .model('form', {
    values: { name: '', email: '', age: 0 },
    errors: {} as Record<string, string>,
    touched: {} as Record<string, boolean>,
    submitting: false
  })
  .build()

const app = createApp(FormSchema)

// Validation
const validateField = (field: string, value: any) => {
  if (field === 'email' && !value.includes('@')) {
    return 'Invalid email'
  }
  return null
}

// Form view with automatic validation
createView(app.models.form, container, (state, ctx) => html`
  <form @submit=${(e: Event) => {
    e.preventDefault()
    ctx.update(state => { state.submitting = true })
    submitForm(state.values)
  }}>
    <input
      name="email"
      .value=${ctx.bind('values.email').value}
      @input=${ctx.bind('values.email').onChange}
      @blur=${() => {
        const error = validateField('email', state.values.email)
        ctx.updateAt('errors.email', () => error)
        ctx.updateAt('touched.email', () => true)
      }}
    />
    ${state.errors.email && state.touched.email ? 
      html`<div class="error">${state.errors.email}</div>` : ''
    }
    
    <button type="submit" ?disabled=${state.submitting}>
      ${state.submitting ? 'Submitting...' : 'Submit'}
    </button>
  </form>
`)

Async Data Loading

const [onLoadUser, emitLoadUser] = createEvent<{ id: string }>()
const [onUserLoaded, emitUserLoaded] = createEvent<User>()
const [onUserError, emitUserError] = createEvent<Error>()

const userState = createSubject(
  { data: null, loading: false, error: null },
  onLoadUser(() => () => ({ data: null, loading: true, error: null })),
  onUserLoaded(user => () => ({ data: user, loading: false, error: null })),
  onUserError(error => () => ({ data: null, loading: false, error }))
)

// Side effect for API calls
onLoadUser.subscribe(async ({ id }) => {
  try {
    const user = await fetchUser(id)
    emitUserLoaded(user)
  } catch (error) {
    emitUserError(error)
  }
})

// View with suspense-like behavior
createView(userModel, container, (state, ctx) => html`
  ${suspense(state, {
    loading: html`<div class="spinner">Loading...</div>`,
    error: (error) => html`<div class="error">Error: ${error.message}</div>`,
    success: (user) => html`<div>Welcome, ${user.name}!</div>`
  })}
`)

Real-time Updates

const [onSocketMessage, emitSocketMessage] = createEvent<SocketMessage>()

// Different message types
const [userMessages, systemMessages, errorMessages] = createPartition(
  onSocketMessage,
  msg => msg.type === 'user' ? 0 : msg.type === 'system' ? 1 : 2
)

const chatState = createSubject(
  { messages: [], users: [], errors: [] },
  userMessages(msg => state => ({ 
    ...state, 
    messages: [...state.messages, msg] 
  })),
  systemMessages(msg => state => ({
    ...state,
    users: msg.type === 'user_joined' ? [...state.users, msg.user] : state.users
  })),
  errorMessages(msg => state => ({
    ...state,
    errors: [...state.errors, msg.error]
  }))
)

// WebSocket integration
const socket = new WebSocket('ws://localhost:8080')
socket.onmessage = (event) => {
  emitSocketMessage(JSON.parse(event.data))
}

Functional Controller Pattern

The Functional Controller pattern decouples business logic (actions/reactions) from state definitions (schemas), allowing your application to scale horizontally without making schema files thousands of lines long.

import { createModelEvent, createModelSubject } from 'gpui-ts'

// Define schema (clean and focused)
const TodoSchema = createSchema()
  .model('todos', {
    items: [] as Array<{ id: number; text: string; completed: boolean }>
  })
  .build()

const app = createApp(TodoSchema)

// === Controller file: features/todos/controller.ts ===

// READS: Reactive subjects that auto-sync with model
export const todoCount = createModelSubject(
  app.models.todos,
  state => state.items.length
)

export const activeCount = createModelSubject(
  app.models.todos,
  state => state.items.filter(t => !t.completed).length
)

export const completedCount = createModelSubject(
  app.models.todos,
  state => state.items.filter(t => t.completed).length
)

// WRITES: Events wired directly to model updates
export const [onAddTodo, addTodo] = createModelEvent(
  app.models.todos,
  (text: string, state, ctx) => {
    state.items.push({ id: Date.now(), text, completed: false })
    // Context available for ModelAPI targets
    ctx?.emit({ type: 'todo:added', text })
  }
)

export const [onToggleTodo, toggleTodo] = createModelEvent(
  app.models.todos,
  (id: number, state) => {
    const todo = state.items.find(t => t.id === id)
    if (todo) todo.completed = !todo.completed
  }
)

export const [onDeleteTodo, deleteTodo] = createModelEvent(
  app.models.todos,
  (id: number, state) => {
    state.items = state.items.filter(t => t.id !== id)
  }
)

// === View file: features/todos/view.ts ===

// Import only what you need - fully decoupled from App/Model instances
import { addTodo, toggleTodo, deleteTodo, todoCount, activeCount } from './controller'

createView(app.models.todos, container, (state) => html`
  <div>
    <h1>Todos (${todoCount()})</h1>
    <p>${activeCount()} active, ${completedCount()} completed</p>

    <input
      id="new-todo"
      placeholder="What needs to be done?"
      @keydown=${(e: KeyboardEvent) => {
        if (e.key === 'Enter') {
          const input = e.target as HTMLInputElement
          addTodo(input.value)
          input.value = ''
        }
      }}
    />

    <ul>
      ${state.items.map(todo => html`
        <li>
          <input
            type="checkbox"
            .checked=${todo.completed}
            @change=${() => toggleTodo(todo.id)}
          />
          <span class=${todo.completed ? 'completed' : ''}>
            ${todo.text}
          </span>
          <button @click=${() => deleteTodo(todo.id)}>×</button>
        </li>
      `)}
    </ul>
  </div>
`)

Benefits of this pattern:

  • Refactoring safety: Move app.models.todos to app.models.work.todos? Only update the controller file
  • Testability: Test actions in isolation without spinning up the whole UI
  • No magic strings: Call functions like addTodo() instead of emitting 'todoAdded' events
  • Type inference: Rarely write <Type> brackets - TypeScript infers everything from the target
  • Lens support: Works with both ModelAPI and FocusedModel for nested updates
// Advanced: Using with FocusedModel for nested state
const profileLens = lens(
  (state: UserState) => state.profile,
  (state, profile) => ({ ...state, profile })
)

const focused = app.models.user.focus(profileLens)

// Create events scoped to the focused subset
export const [onUpdateBio, updateBio] = createModelEvent(
  focused,
  (bio: string, draft) => {
    draft.bio = bio
    draft.lastUpdated = Date.now()
    // Note: context is undefined for FocusedModel
  }
)

// Create subjects for focused data
export const userBio = createModelSubject(focused, profile => profile.bio)

// Usage
updateBio('Full-stack developer')
console.log(userBio()) // "Full-stack developer"

Unified Event Bus with createAppEvent

For applications that need a unified event system where all actions emit traceable events, use createAppEvent. This variant integrates with GPUI-TS's event infrastructure, automatically registering events at runtime and maintaining type safety across the entire app.

import { createAppEvent, createSchema } from 'gpui-ts'

// Simple schema without events defined
const schema = createSchema()
  .model('todos', { items: [] as Array<{id: number; text: string; completed: boolean}> })
  .build()

let app = createApp(schema)

// createAppEvent registers events at runtime and returns a typed app
const [onAdd, addTodo, appWithAdd] = createAppEvent(
  app,
  'todos',
  'todoAdded',
  (text: string, priority: 'high' | 'low') => ({
    text,
    priority,
    timestamp: Date.now()
  }),
  (payload, state, ctx) => {
    // Handler receives fully typed payload
    state.items.push({
      id: Date.now(),
      text: payload.text,
      completed: false
    })
    // Event automatically emitted to listeners
  }
)

app = appWithAdd  // Update app reference to get new types

// Chain multiple events - each returns typed app
const [onToggle, toggleTodo, appWithToggle] = createAppEvent(
  app,
  'todos',
  'todoToggled',
  (id: number) => ({ id }),
  (payload, state) => {
    const todo = state.items.find(t => t.id === payload.id)
    if (todo) todo.completed = !todo.completed
  }
)

app = appWithToggle

// Now app has both events typed - subscribe to them
app.models.todos.on.todoAdded(payload => {
  console.log(`Todo added: ${payload.text} (${payload.priority})`)
  // Payload is fully typed: { text: string, priority: 'high' | 'low', timestamp: number }
})

app.models.todos.on.todoToggled(payload => {
  console.log(`Todo toggled: ${payload.id}`)
})

// Use the actions
addTodo('Buy milk', 'high')   // Emits 'todos:todoAdded' event
toggleTodo(1)                 // Emits 'todos:todoToggled' event

When to use createAppEvent vs createModelEvent:

  • Use createModelEvent for simple controllers where you don't need event infrastructure
  • Use createAppEvent when you want:
    • A unified event bus for analytics, logging, or debugging
    • Events to be traceable across the application
    • Schema events to be registered at runtime with full type safety
    • EventHandler transformation chains (.map(), .filter(), etc.)

FAQ

Q: How does GPUI-TS compare to React's built-in state management?

GPUI-TS provides centralized, reactive state that can be shared across your entire application without prop drilling or complex Context setups. React's useState is component-local, while GPUI-TS models are global and reactive.

Q: Can I use GPUI-TS with React?

Yes! GPUI-TS is framework-agnostic. You can subscribe to model changes in React components:

function MyReactComponent() {
  const [state, setState] = useState(todoModel.read())
  
  useEffect(() => {
    return todoModel.onChange(newState => setState(newState))
  }, [])
  
  return <div>{state.items.length} todos</div>
}

Q: How does performance compare to other state management solutions?

GPUI-TS uses automatic batching and fine-grained reactivity. Updates are queued and flushed synchronously, preventing cascading re-renders. The lit-html integration only updates changed DOM nodes.

Q: What's the learning curve like?

If you're familiar with Redux, the concepts translate well but with less boilerplate. If you know RxJS, the event composition patterns will feel natural. The hardest part is typically understanding centralized model ownership vs. local component state.

Q: How do I handle side effects?

GPUI-TS has built-in effect systems:

model.update((state, ctx) => {
  state.data = newData
  ctx.effect((currentState, cleanup) => {
    const timer = setInterval(() => console.log(currentState), 1000)
    cleanup(() => clearInterval(timer))
  })
})

Q: Can I gradually adopt GPUI-TS in an existing app?

Yes! Start by converting a single piece of global state to a GPUI-TS model. The framework is designed for gradual adoption.

Q: How do I debug state changes?

GPUI-TS includes development tools:

// Enable debug mode
enableDevMode(app)

// Access debug info in browser console
window.__GPUI_DEBUG__.logAllState()
window.__GPUI_DEBUG__.analyzePerformance()

// Time travel debugging
const snapshot = model.snapshot()
// ... make changes ...
model.restore(snapshot)

Q: What about TypeScript support?

GPUI-TS is built with TypeScript-first design. Schema definitions drive complete type inference throughout your app, eliminating the need for manual type annotations in most cases.

Performance

Built-in Performance Features

GPUI-TS includes several built-in optimizations that work automatically:

1. Configurable Selector Memoization

  • Deep equality (default): Safe for all use cases, recomputes only when values change
  • Shallow equality: 50x faster for large arrays, use when reference equality is sufficient
  • Custom equality: Define your own comparison logic for specific needs
// Default: deep equality (safe, comprehensive)
const selector1 = createSelector([selectItems], items => items.filter(...))

// Opt-in: shallow equality (fast for large arrays)
const selector2 = createSelector(
  [selectItems],
  items => items.length,
  { equalityFn: shallowEqual }
)

2. Bounded Cache Strategies

  • Unbounded (default): Best for stable inputs, unlimited cache
  • LRU cache: Best for pagination, user switching, search - keeps N most recently used
  • FIFO cache: Best for streaming/time-series - keeps N most recently added
// LRU cache prevents memory growth with dynamic inputs
const userSelector = createSelector(
  [selectUserId],
  userId => fetchUserData(userId),
  { cacheStrategy: 'lru', maxCacheSize: 10 }
)

3. Proxy Batching

  • Group multiple proxy mutations into a single notification
  • Reduces re-renders from N to 1 for N updates
// Without batching: 3 separate notifications
userProxy.name = 'Jane'
userProxy.age = 30
userProxy.city = 'NYC'

// With batching: 1 notification
app.models.user.batch(() => {
  userProxy.name = 'Jane'
  userProxy.age = 30
  userProxy.city = 'NYC'
})

Optimization Tips

  1. Use batch operations for multiple updates:
app.batch(() => {
  model1.update(...)
  model2.update(...)
  model3.update(...)
}) // Single re-render
  1. Choose the right selector strategy:
// Simple selectors: unbounded cache (default)
const selectCount = createSelector([selectItems], items => items.length)

// Dynamic inputs (pagination): LRU cache
const selectPage = createSelector(
  [selectPageNum],
  page => fetchPage(page),
  { cacheStrategy: 'lru', maxCacheSize: 5 }
)

// Large arrays: shallow equality
const selectIds = createSelector(
  [selectItems],
  items => items.map(i => i.id),
  { equalityFn: shallowEqual }
)
  1. Use path-based updates for deep objects:
// ✅ Efficient: only updates specific path
model.updateAt('user.profile.settings.theme', theme => theme === 'dark' ? 'light' : 'dark')

// ❌ Inefficient: updates entire state
model.update(state => {
  state.user.profile.settings.theme = state.user.profile.settings.theme === 'dark' ? 'light' : 'dark'
})

Performance Benchmarks

Selector Memoization:

  • Deep equality with 10,000 items: ~5ms per comparison
  • Shallow equality with 10,000 items: ~0.1ms per comparison (50x faster)
  • LRU cache memory: O(maxCacheSize) vs O(∞) for unbounded

Batching:

  • 10 proxy updates without batching: ~100ms (10 re-renders)
  • 10 proxy updates with batching: ~10ms (1 re-render, 10x faster)

Resources

Related Projects

Contributing


GPUI-TS - Reactive state management that scales from simple counters to complex applications.

Get Started | API Docs | Examples | GitHub

About

A JavaScript library inspired by gpui and solid-events

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •