Reactive Models
Gluonic uses MobX-powered reactive models to automatically update your UI when data changes. No manual state management required.
The Problem
Traditional React requires manual state updates:
// Traditional React
const [post, setPost] = useState(null)
useEffect(() => {
// Initial load
fetchPost('123').then(setPost)
}, [])
const handleSave = async () => {
const updated = await savePost('123', { title: 'New' })
setPost(updated) // Manual update 😔
}
// Every component that shows this post needs:
// - useState for the data
// - useEffect to load it
// - Manual updates on changesResult: Lots of boilerplate, easy to forget updates, bugs.
The Gluonic Solution
Models are automatically reactive - change the data, UI updates automatically:
// Gluonic
const PostDetail = observer(({ postId }) => {
// Just read the data
const post = useModel<Post>('post', postId)
if (!post) return <div>Not found</div>
// Update it anywhere, anytime
const handleSave = () => {
store.save('post', postId, { title: 'New' })
// UI updates automatically ✓
}
return <h1>{post.title}</h1>
// Re-renders automatically when title changes ✓
})How It Works
1. Observable Data Pool
The pool is a MobX observable:
class ObjectPool {
// MobX map - tracks all reads/writes
map = observable.map<string, WireRow>({}, { deep: false })
get(t: string, id: string): WireRow | null {
return this.map.get(`${t}:${id}`) || null
}
upsert(row: WireRow): void {
this.map.set(`${row.t}:${row.id}`, row)
// MobX broadcasts change to observers ✓
}
}2. Proxy-Based Field Access
Models read from pool via JavaScript Proxy:
const post = new Proxy(new Post(), {
get(target, prop) {
if (prop === 'title') {
// Read from pool (MobX tracks this!)
const row = store.pool.get('post', '123')
return row?.p.title
}
},
set(target, prop, value) {
if (prop === 'title') {
// Write through store
store.save('post', '123', { title: value })
return true
}
}
})
// Reading post.title → MobX knows "this component depends on post:123.title"
// Writing post.title → MobX notifies all dependent components3. Observer HOC
Components wrapped with observer() auto-update:
import { observer } from '@gluonic/client'
const PostDetail = observer(({ postId }) => {
const post = useModel<Post>('post', postId)
// This read is tracked:
return <h1>{post.title}</h1>
// MobX knows: "PostDetail depends on post:123.title"
})
// Later, when data changes:
store.save('post', '123', { title: 'Updated' })
// MobX detects: "post:123.title changed"
// MobX triggers: "PostDetail needs to re-render"
// React re-renders: PostDetail component
// User sees: Updated title ✓Complete Flow
// 1. Component mounts
const PostDetail = observer(({ postId }) => {
const post = useModel<Post>('post', postId)
// ↓ GraphBridge.getModel()
// ↓ Returns Proxy wrapping Post instance
// 2. Component reads field
return <h1>{post.title}</h1>
// ↓ Proxy GET trap
// ↓ store.pool.get('post', '123').p.title
// ↓ MobX tracks: "PostDetail reads post:123.title"
})
// 3. Data changes (anywhere in the app)
await store.save('post', '123', { title: 'New Title' })
// ↓ Pool updates
// ↓ store.pool.upsert({ t: 'post', id: '123', p: { title: 'New Title' } })
// ↓ MobX detects: "post:123.title changed"
// ↓ MobX finds: "PostDetail depends on post:123.title"
// ↓ MobX schedules: Re-render PostDetail
// 4. Component re-renders
// Reads post.title again → gets "New Title"
// UI updates automatically ✓Benefits
No Manual State Management
// ❌ Traditional
const [post, setPost] = useState()
const [loading, setLoading] = useState(true)
useEffect(() => { ... }, [])
const handleSave = () => {
setPost({ ...post, title: 'New' }) // Manual update
}
// ✅ Gluonic
const post = useModel<Post>('post', postId)
const handleSave = () => {
store.save('post', postId, { title: 'New' })
// That's it! UI updates automatically ✓
}Precise Re-Renders
Only components that read changed data re-render:
// Component A reads post.title
const A = observer(() => {
const post = useModel<Post>('post', '123')
return <h1>{post.title}</h1>
})
// Component B reads post.content
const B = observer(() => {
const post = useModel<Post>('post', '123')
return <p>{post.content}</p>
})
// Update title
store.save('post', '123', { title: 'New' })
// Result:
// - Component A re-renders ✓ (reads title)
// - Component B doesn't re-render ✓ (doesn't read title)
// - Efficient! 🚀Works Across Component Tree
Data changes propagate automatically:
// Deeply nested component
const DeepComponent = observer(() => {
const post = useModel<Post>('post', '123')
return <span>{post.title}</span>
})
// Top-level action
const Header = () => {
const handleEdit = () => {
store.save('post', '123', { title: 'New' })
// DeepComponent updates automatically ✓
// No prop drilling needed ✓
}
return <button onClick={handleEdit}>Edit</button>
}MobX Basics
Observer HOC
Rule: Any component that reads model data MUST be wrapped with observer():
// ✅ Correct
const PostTitle = observer(({ postId }) => {
const post = useModel<Post>('post', postId)
return <h1>{post.title}</h1> // Will update ✓
})
// ❌ Wrong
const PostTitle = ({ postId }) => {
const post = useModel<Post>('post', postId)
return <h1>{post.title}</h1> // Won't update! 💥
}Computed Properties
Models can have computed properties:
@ClientModel('post')
export class Post extends Model {
@Property()
content: string = ''
// Computed (no decorator needed)
get wordCount(): number {
return this.content.split(/\s+/).length
}
get readTime(): string {
const minutes = Math.ceil(this.wordCount / 200)
return `${minutes} min read`
}
}
// In component
const PostMeta = observer(({ post }) => {
return <span>{post.readTime}</span>
// Auto-updates when content changes ✓
})Batched Updates
MobX batches multiple changes into single re-render:
// Multiple updates in same tick
store.save('post', '123', { title: 'New Title' })
store.save('post', '123', { content: 'New Content' })
store.save('post', '123', { published: true })
// MobX batches these
// Component re-renders ONCE (not 3 times) ✓Best Practices
DO ✅
// Wrap components with observer
const Component = observer(() => { ... })
// Read data directly
const post = useModel<Post>('post', postId)
return <h1>{post.title}</h1>
// Use computed properties
get fullName() {
return `${this.firstName} ${this.lastName}`
}
// Trust MobX batching
store.save('post', id, { title: 'A' })
store.save('post', id, { content: 'B' })
// Batched automatically ✓DON’T ❌
// Don't forget observer()
const Component = () => { ... } // ❌ Won't update!
// Don't use useState for model data
const [title, setTitle] = useState(post.title) // ❌ Redundant
// Don't manually trigger re-renders
const [, forceUpdate] = useReducer(x => x + 1, 0)
store.save(...)
forceUpdate() // ❌ Unnecessary
// Don't destructure models
const { title, content } = post // ❌ Breaks reactivity!
// Use: post.title, post.contentDebugging Reactivity
Check if Component is Observer
// In component
console.log('Is observer?', Component.isMobXReactObserver)Track What’s Being Observed
import { trace } from 'mobx'
const Component = observer(() => {
trace() // Logs all observed values
const post = useModel<Post>('post', postId)
return <h1>{post.title}</h1>
})Verify Pool is Observable
// Check pool reactivity
console.log(isObservable(store.pool.map)) // Should be truePerformance
Reactive models are extremely efficient:
- Proxy access: < 0.001ms per field read
- MobX tracking: < 0.001ms per access
- Re-render decision: < 0.01ms
- Identity mapping: O(1) lookup
Benchmark: 1000 models with 100 fields each = 5ms total instantiation time.
Related Concepts
- Identity Mapping - Same ID = same instance
- Lazy Loading - On-demand relationships
- Optimistic Updates - Instant UI feedback