A JavaScript framework inspired by gpui and solid-events
npm install 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.
- Why GPUI-TS?
- Quick Start
- Core Concepts
- API Reference
- Comparison with Other Frameworks
- Common Patterns
- FAQ
- Performance
- Resources
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:
``typescript
// 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
- ${todo.text}
)})
`Quick Start
$3
`bash
npm install gpui-ts lit-html
`$3
`typescript
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
)
`$3
`typescript
// Create events with transformation chains
const [onIncrement, emitIncrement] = createEvent()
const [onReset, emitReset] = createEvent()// 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
$3
Unlike Redux or Zustand, GPUI-TS models own their state completely. Updates go through a controlled API:
`typescript
// ❌ 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
`$3
For simple state updates, GPUI-TS provides an optional proxy API that feels like direct object manipulation while maintaining all the safety guarantees:
`typescript
// 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`typescript
// 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
`$3
Events support transformation chains inspired by solid-events:
`typescript
const [onUserInput, emitUserInput] = createEvent()// 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(() => [])
)
`$3
Subjects automatically update when their dependencies change:
`typescript
// 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
}))
)
`$3
Lenses provide functional, immutable access to nested data structures with full type safety:
`typescript
// 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
})
`$3
GPUI-TS supports both global events and model-scoped events with full type safety:
`typescript
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: 'user@example.com' } })
})
`$3
Create memoized selectors for computed values with automatic dependency tracking and configurable caching:
`typescript
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
Total: ${stats.total}
Completed: ${stats.completed}
Active: ${stats.active}
- ${todo.text}
)}
})
`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
$3
GPUI-TS supports sophisticated event composition patterns inspired by solid-events:
`typescript
import { createEvent, createTopic, createPartition, createSubject } from 'gpui-ts'// Event transformation chains
const [onUserInput, emitUserInput] = createEvent()
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 }))
)
`$3
Add events to running applications at runtime:
`typescript
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 } })
})
`$3
Schemas drive complete type inference:
`typescript
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.
$3
For applications that need to add or remove features dynamically (e.g., code-splitting, plugins):
`typescript
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
`$3
For composing schemas from multiple modules before app creation:
`typescript
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
$3
####
createAppCreates a GPUI application with full type inference from schema.
`typescript
const app = createApp(MySchema)
// app.models.* are fully typed based on schema
`####
createSchema()Fluent builder for app schemas:
`typescript
const schema = createSchema()
.model('todos', { items: [] })
.events({ todoAdded: { payload: { text: string } } })
.plugin(uiStatePlugin)
.build()
`####
createEventCreates event handler and emitter with transformation support:
`typescript
const [onEvent, emitEvent] = createEvent()// Transform with solid-events style chaining
const transformed = onEvent
.filter(payload => isValid(payload))
.map(payload => normalize(payload))
`####
createSubjectCreates reactive state that responds to events:
`typescript
const count = createSubject(
0,
onIncrement(delta => current => current + delta),
onReset(() => 0)
)
`####
addModelDynamically adds a new model to a running GPUI application:
`typescript
let app = createApp(MySchema)
app = addModel(app, 'posts', {
initialState: { items: [], loading: false }
})
// app.models.posts is now available and fully typed
`####
removeModelRemoves a model from a running GPUI application and cleans up resources:
`typescript
app = removeModel(app, 'posts')
// app.models.posts is now undefined and TypeScript knows it's gone
`####
addEventAdds a new event definition to the application schema:
`typescript
app = addEvent(app, 'postCreated', {
payload: { title: string, content: string }
})
`####
addModelToSchemaBuild-time helper for adding models to schema builders:
`typescript
let builder = createSchema().model('user', { name: '' })
builder = addModelToSchema(builder, 'posts', { items: [] })
`####
removeModelFromSchemaBuild-time helper for removing models from schema builders:
`typescript
builder = removeModelFromSchema(builder, 'posts')
`####
addEventToSchemaBuild-time helper for adding events to schema builders:
`typescript
builder = addEventToSchema(builder, 'login', { payload: { email: '' } })
`$3
`typescript
interface ModelAPI {
// State access
read(): T
readAt>(path: P): PathValue
// Updates
update(updater: (state: T, ctx: ModelContext) => void): this
updateAt>(path: P, updater: (value: PathValue) => PathValue): this
updateIf(guard: (state: T) => state is TGuard, updater: (state: TGuard, ctx: ModelContext) => void): this
updateAndNotify(updater: (state: T) => void, onError?: (error: unknown, initialState: DeepReadonly) => void): this
// Helper methods for common state manipulations
set
>(path: P, value: PathValue): this
toggle>(path: PathValue extends boolean ? P : never): this
reset(): this
push>(path: P, ...items: PathValue extends (infer U)[] ? U[] : never): this
removeWhere>(path: P, predicate: (item: PathValue extends (infer U)[] ? U : never) => boolean): this
updateAsync(
updater: (state: T) => Promise>,
options: {
loadingKey: PathValue extends boolean ? LoadingKey : never
errorKey: ErrorKey
onError?: (error: unknown, initialState: DeepReadonly) => void
}
): Promise
// 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(event: TEvent): this
onEvent(handler: (event: TEvent) => void): () => void
// Subscriptions
onChange(listener: (current: T, previous: T) => void): () => void
subscribeTo(source: ModelAPI, reaction: (source: TSource, target: T, ctx: ModelContext) => void): ModelSubscription
// Advanced
lens(getter: (state: T) => TFocus): Lens
focus(lens: Lens): FocusedModel
transaction(work: (ctx: ModelContext) => TResult): TResult
snapshot(): ModelSnapshot
validate(): ValidationResult
}
`####
createSelectorCreates a memoized selector function with deep equality checking for optimal performance:
`typescript
// 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
}
)
`####
createModelSelectorCreates a model-specific selector that automatically provides the model's current state:
`typescript
const userFullName = createModelSelector(app.models.user, userDisplayName)
const name = userFullName() // Automatically uses current user model state
`####
createTopicMerges multiple event sources into a single event stream:
`typescript
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))
`####
createPartitionSplits events into multiple streams based on a partitioning function:
`typescript
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))
`$3
`typescript
// Create reactive views
createView(model, container, (state, ctx) => html)// Component-style views
const MyComponent = createComponent<{name: string}, {count: number}>((props) => ({
state: createSubject({ count: 0 }),
template: (state, ctx) => html
}))
`Modules Overview
GPUI-TS is organized into several focused modules, each providing specific functionality:
$3
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
$3
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 monitoringFeatures:
- Automatic re-rendering on state changes
- Type-safe template functions
- Automatic cleanup and lifecycle management
- Performance optimized rendering
- Development mode debugging
$3
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
$3
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 valuesFeatures:
- 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
$3
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 signalsFeatures:
- 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
$3
Signals (
src/signals.ts):
- Reactive primitives for fine-grained reactivity
- Integration with GPUI-TS models
- Signal-based view updatesResources (
src/resource.ts):
- Specialized async state management
- Loading, error, and success state handling
- Automatic dependency trackingInfinite Resources (
src/infinite-resource.ts):
- Pagination and infinite scrolling support
- Virtual scrolling integration
- Memory-efficient large dataset handlingCRDT (
src/crdt.ts):
- Conflict-free replicated data types
- Collaborative editing support
- Operation broadcasting and conflict resolutionRobot (
src/robot.ts):
- State machine and robot pattern implementations
- Complex workflow management
- Hierarchical state handlingEach 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
$3
| 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) |
`typescript
// 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()
`$3
| 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 |
`typescript
// 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 }]))
`$3
| 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 |
$3
| 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 |
`typescript
// 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
$3
`typescript
const FormSchema = createSchema()
.model('form', {
values: { name: '', email: '', age: 0 },
errors: {} as Record,
touched: {} as Record,
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
)
`$3
`typescript
const [onLoadUser, emitLoadUser] = createEvent<{ id: string }>()
const [onUserLoaded, emitUserLoaded] = createEvent()
const [onUserError, emitUserError] = createEvent()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
Loading...,Error: ${error.message},Welcome, ${user.name}!)
`$3
`typescript
const [onSocketMessage, emitSocketMessage] = createEvent()// 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))
}
`$3
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.
`typescript
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
${activeCount()} active, ${completedCount()} completed
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 = ''
}
}}
/>
type="checkbox"
.checked=${todo.completed}
@change=${() => toggleTodo(todo.id)}
/>
${todo.text}
)})
`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 brackets - TypeScript infers everything from the target
- Lens support: Works with both ModelAPI and FocusedModel for nested updates`typescript
// 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
createAppEventFor 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.`typescript
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
$3
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.
$3
Yes! GPUI-TS is framework-agnostic. You can subscribe to model changes in React components:
`typescript
function MyReactComponent() {
const [state, setState] = useState(todoModel.read())
useEffect(() => {
return todoModel.onChange(newState => setState(newState))
}, [])
return {state.items.length} todos
}
`$3
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.
$3
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.
$3
GPUI-TS has built-in effect systems:
`typescript
model.update((state, ctx) => {
state.data = newData
ctx.effect((currentState, cleanup) => {
const timer = setInterval(() => console.log(currentState), 1000)
cleanup(() => clearInterval(timer))
})
})
`$3
Yes! Start by converting a single piece of global state to a GPUI-TS model. The framework is designed for gradual adoption.
$3
GPUI-TS includes development tools:
`typescript
// 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)
`$3
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
$3
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
`typescript
// 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
`typescript
// 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
`typescript
// 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'
})
`$3
1. Use batch operations for multiple updates:
`typescript
app.batch(() => {
model1.update(...)
model2.update(...)
model3.update(...)
}) // Single re-render
`2. Choose the right selector strategy:
`typescript
// 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 }
)
`3. Use path-based updates for deep objects:
`typescript
// ✅ 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'
})
``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)
---
GPUI-TS - Reactive state management that scales from simple counters to complex applications.
Get Started | API Docs | Examples | GitHub