A lightweight bridge for seamless two-way data binding between MobX observables and Vue 3 reactivity
npm install mobx-vue-bridgeA seamless bridge between MobX observables and Vue 3's reactivity system, enabling effortless two-way data binding and state synchronization.


- ๐ Two-way data binding between MobX observables and Vue reactive state
- ๐ฏ Automatic property detection (properties, getters, setters, methods)
- ๐๏ธ Deep object/array observation with proper reactivity
- โ๏ธ Configurable mutation behavior
- ๐ Type-safe bridging between reactive systems
- ๐ Optimized performance with intelligent change detection
- ๐ก๏ธ Error handling for edge cases and circular references
- โก Zero-side-effect initialization with lazy detection (v1.4.0+)
- ๐ฆ Modular architecture for better maintainability
``bash`
npm install mobx-vue-bridge
Peer Dependencies:
- Vue 3.x
- MobX 6.x
`bash`
npm install vue mobx
First, create your MobX presenter:
`javascript
// presenters/UserPresenter.js
import { makeAutoObservable } from 'mobx'
export class UserPresenter {
constructor() {
this.name = 'John'
this.age = 25
this.emails = []
makeAutoObservable(this)
}
get displayName() {
return ${this.name} (${this.age})`
}
addEmail(email) {
this.emails.push(email)
}
}
Then use it in your Vue component:
Option 1: Modern
`
Option 2: Traditional Composition API
`vue`
Template usage:
` {{ state.displayName }}html`
{{ email }}
Bridges a MobX observable object with Vue's reactivity system.
Parameters:
- mobxObject - The MobX observable object to bridgeoptions
- - Configuration options (optional)
Options:
- allowDirectMutation (boolean, default: true) - Whether to allow direct mutation of properties
Returns: Vue reactive state object
`javascript`
// With configuration
const state = useMobxBridge(store, {
allowDirectMutation: false // Prevents direct mutations
})
Alias for useMobxBridge - commonly used with presenter pattern.
`javascript`
const state = usePresenterState(presenter, options)
javascript
class TodoPresenter {
constructor(todoService) {
this.todoService = todoService
this.todos = []
this.filter = 'all'
this.loading = false
makeAutoObservable(this)
}
get filteredTodos() {
switch (this.filter) {
case 'active': return this.todos.filter(t => !t.completed)
case 'completed': return this.todos.filter(t => t.completed)
default: return this.todos
}
}
async loadTodos() {
this.loading = true
try {
this.todos = await this.todoService.fetchTodos()
} finally {
this.loading = false
}
}
}// In component
const presenter = new TodoPresenter(todoService)
const state = usePresenterState(presenter)
`$3
`javascript
// MobX store
class AppStore {
constructor() {
this.user = null
this.theme = 'light'
this.notifications = []
makeAutoObservable(this)
}
get isAuthenticated() {
return !!this.user
}
setTheme(theme) {
this.theme = theme
}
}// Bridge in component
const state = useMobxBridge(appStore)
`๐ง Advanced Features
$3
The bridge accepts an optional configuration object to customize its behavior:
`javascript
const state = useMobxBridge(mobxObject, {
allowDirectMutation: true // default: true
})
`####
allowDirectMutation (boolean)
Controls whether direct mutations are allowed on the Vue state:
- true (default): Allows state.name = 'New Name'
- false: Mutations must go through MobX actions`javascript
// Allow direct mutations (default)
const state = useMobxBridge(presenter, { allowDirectMutation: true })
state.name = 'John' // โ
Works// Disable direct mutations (action-only mode)
const state = useMobxBridge(presenter, { allowDirectMutation: false })
state.name = 'John' // โ Warning: use actions instead
presenter.setName('John') // โ
Works
`
- โ
You can use await nextTick() when needed for immediate reads$3
The bridge automatically handles deep changes in objects and arrays:`javascript
// These mutations are automatically synced
state.user.profile.name = 'New Name' // Object mutation
state.todos.push(newTodo) // Array mutation
state.settings.colors[0] = '#FF0000' // Nested array mutation
`Note on Async Behavior: Nested mutations (via the deep proxy) are batched using
queueMicrotask() to prevent corruption during array operations like shift(), unshift(), and splice(). This ensures data correctness. If you need immediate access to updated values after nested mutations in the same function, use Vue's nextTick():`javascript
import { nextTick } from 'vue'state.items.push(newItem)
await nextTick() // Wait for batched update to complete
console.log(state.items) // Now updated
`However, Vue templates, computed properties, and watchers work automatically without
nextTick():`vue
{{ state.items.length }}
`Top-level property assignments are synchronous:
`javascript
state.count = 42 // Immediate (sync)
state.items = [1, 2, 3] // Immediate (sync)
state.items.push(4) // Batched (async - requires nextTick for immediate read)
`Best Practice: Keep business logic in your MobX Presenter. When you mutate via the Presenter, everything is synchronous:
`javascript
// โ
Presenter pattern - always synchronous, no nextTick needed
presenter.items.push(newItem)
console.log(presenter.items) // Immediately updated!
`๐๏ธ Architecture & Implementation
$3
The bridge uses a clean, modular architecture for better maintainability:
`
src/
โโโ mobxVueBridge.js # Main bridge (321 lines)
โโโ utils/
โโโ memberDetection.js # MobX property categorization (210 lines)
โโโ equality.js # Deep equality with circular protection (47 lines)
โโโ deepProxy.js # Nested reactivity with batching (109 lines)
`$3
The bridge uses lazy detection to avoid calling setters during initialization:
`javascript
class GuestPresenter {
get currentRoom() {
return this.repository.currentRoomId
}
set currentRoom(val) {
this.repository.currentRoomId = val
this.refreshDataOnTabChange() // Side effect!
}
}// โ
v1.4.0+: No side effects during bridge creation
const state = useMobxBridge(presenter) // refreshDataOnTabChange() NOT called
// โ
Side effects only happen during actual mutations
state.currentRoom = 'room-123' // refreshDataOnTabChange() called here
`How it works:
1. Detection phase: Checks descriptor existence only (
descriptor.get && descriptor.set)
2. First write: Tests if setter actually works by attempting the write
3. Caching: Results stored in readOnlyDetected Set for O(1) future lookupsThis prevents bugs where setter side effects were triggered during bridge setup, while maintaining accurate runtime behavior.
$3
The bridge gracefully handles edge cases:- Uninitialized computed properties
- Circular references
- Failed setter operations
- Missing dependencies
$3
- Intelligent change detection prevents unnecessary updates
- Efficient shallow/deep equality checks
- Minimal overhead for large object graphs๐งช Testing
`bash
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
``Contributions are welcome! Please feel free to submit a Pull Request.
MIT ยฉ Visar Uruqi