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:
// 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>
`)npm install gpui-ts lit-htmlimport { 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>
`)// 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()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 stateFor 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 completeEvents 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 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 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
})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]' } })
})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
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 }))
)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 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'GPUI-TS supports dynamic schema modifications at runtime and build time, enabling advanced patterns like code-splitting, plugins, and modular architectures.
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 goneFor 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 todosCreates a GPUI application with full type inference from schema.
const app = createApp(MySchema)
// app.models.* are fully typed based on schemaFluent builder for app schemas:
const schema = createSchema()
.model('todos', { items: [] })
.events({ todoAdded: { payload: { text: string } } })
.plugin(uiStatePlugin)
.build()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))Creates reactive state that responds to events:
const count = createSubject(
0,
onIncrement(delta => current => current + delta),
onReset(() => 0)
)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 typedRemoves 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 goneAdds a new event definition to the application schema:
app = addEvent(app, 'postCreated', {
payload: { title: string, content: string }
})Build-time helper for adding models to schema builders:
let builder = createSchema().model('user', { name: '' })
builder = addModelToSchema(builder, 'posts', { items: [] })Build-time helper for removing models from schema builders:
builder = removeModelFromSchema(builder, 'posts')Build-time helper for adding events to schema builders:
builder = addEventToSchema(builder, 'login', { payload: { email: '' } })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>
}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
}
)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 stateMerges 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))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))// 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>
`
}))GPUI-TS is organized into several focused modules, each providing specific functionality:
The foundation of GPUI-TS, providing the core state management engine.
Key Exports:
createApp()- Creates the main application instancecreateSchema()- Fluent schema buildercreateEvent()- Event system with transformation chainscreateSubject()- Reactive state containersModelAPI- Complete model interface with all state operationsModelRegistry- Central state management and effect queuingLens- Composable data access and updatesCRDTManager- Conflict-free replicated data types supportcreateReducer()- 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
Seamless integration with lit-html for reactive rendering.
Key Exports:
createView()- Reactive view binding to modelscreateComponent()- Component-style view compositionbind()- Form input binding directivewhen()- Conditional rendering directiveforEach()- List rendering directiveasyncTemplate()- Async operation renderingsuspense()- Loading/error/success state renderingdevView()- Development mode debuggingperformanceView()- Performance monitoring
Features:
- Automatic re-rendering on state changes
- Type-safe template functions
- Automatic cleanup and lifecycle management
- Performance optimized rendering
- Development mode debugging
Type-safe utilities for building and manipulating schemas.
Key Exports:
createSchema()- Fluent schema buildercreateModelSchema()- Advanced model schema configurationmergeSchemas()- Schema compositionvalidators- Built-in validation rulescombineValidators()- Validation compositionvalidateSchema()- Schema validationintrospectSchema()- Schema analysisgenerateTypes()- 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
Powerful patterns for complex state management scenarios.
Key Exports:
createReactiveView()- Fine-grained reactivity with signalscreateResource()- Formalized async state managementcreateMachineModel()- XState integrationSignal- Reactive primitive for fine-grained updatesComputed- 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
Composition API-style interface using unctx for cleaner setup code.
Key Exports:
createAppWithContext()- Context-aware app creationuseApp()- Access active application instanceuseModel()- Direct model access by nameuseResource()- Context-aware resource creationuseMachineModel()- Context-aware state machine integrationuseSignalFromModel()- 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
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.
| 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()| 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 }]))| 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 |
| 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))
)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>
`)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>`
})}
`)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))
}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.todostoapp.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
ModelAPIandFocusedModelfor 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"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' eventWhen to use createAppEvent vs createModelEvent:
- Use
createModelEventfor simple controllers where you don't need event infrastructure - Use
createAppEventwhen 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.)
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.
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>
}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.
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.
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))
})
})Yes! Start by converting a single piece of global state to a GPUI-TS model. The framework is designed for gradual adoption.
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)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.
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'
})- Use batch operations for multiple updates:
app.batch(() => {
model1.update(...)
model2.update(...)
model3.update(...)
}) // Single re-render- 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 }
)- 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'
})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)
- lit-html - Template library
- solid-events - Event composition inspiration
GPUI-TS - Reactive state management that scales from simple counters to complex applications.
Get Started | API Docs | Examples | GitHub