Skip to content

Once upon a time, there was a MobX object that wanted to live in Vue's reactive world. This bridge helps them understand each other by translating between their languages.

License

Notifications You must be signed in to change notification settings

visaruruqi/mobx-vue-bridge

πŸŒ‰ MobX-Vue Bridge

A seamless bridge between MobX observables and Vue 3's reactivity system, enabling effortless two-way data binding and state synchronization.

npm version License: MIT

✨ Features

  • πŸ”„ 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

πŸ“¦ Installation

npm install mobx-vue-bridge

Peer Dependencies:

  • Vue 3.x
  • MobX 6.x
npm install vue mobx

πŸš€ Quick Start

First, create your MobX presenter:

// 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 <script setup> syntax (recommended)

<script setup>
import { useMobxBridge } from 'mobx-vue-bridge'
import { UserPresenter } from './presenters/UserPresenter.js'

const userPresenter = new UserPresenter()

// Bridge MobX observable to Vue reactive state
const state = useMobxBridge(userPresenter)
</script>

Option 2: Traditional Composition API

<script>
import { useMobxBridge } from 'mobx-vue-bridge'
import { UserPresenter } from './presenters/UserPresenter.js'

export default {
  setup() {
    const userPresenter = new UserPresenter()
    
    // Bridge MobX observable to Vue reactive state
    const state = useMobxBridge(userPresenter)
    
    return {
      state
    }
  }
}
</script>

Template usage:

<template>
  <div>
    <!-- Two-way binding works seamlessly -->
    <input v-model="state.name" />
    <input v-model.number="state.age" />
    
    <!-- Computed properties are reactive -->
    <p>{{ state.displayName }}</p>
    
    <!-- Methods are properly bound -->
    <button @click="state.addEmail('new@email.com')">
      Add Email
    </button>
    
    <!-- Arrays/objects are deeply reactive -->
    <ul>
      <li v-for="email in state.emails" :key="email">
        {{ email }}
      </li>
    </ul>
  </div>
</template>

πŸ“š API Reference

useMobxBridge(mobxObject, options?)

Bridges a MobX observable object with Vue's reactivity system.

Parameters:

  • mobxObject - The MobX observable object to bridge
  • options - Configuration options (optional)

Options:

  • allowDirectMutation (boolean, default: true) - Whether to allow direct mutation of properties

Returns: Vue reactive state object

// With configuration
const state = useMobxBridge(store, {
  allowDirectMutation: false // Prevents direct mutations
})

usePresenterState(presenter, options?)

Alias for useMobxBridge - commonly used with presenter pattern.

const state = usePresenterState(presenter, options)

🎯 Use Cases

Presenter Pattern

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)

Store Integration

// 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

Configuration Options

The bridge accepts an optional configuration object to customize its behavior:

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
// 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

Deep Reactivity

The bridge automatically handles deep changes in objects and arrays:

// 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():

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():

<template>
  <!-- Auto-updates, no nextTick needed -->
  <div>{{ state.items.length }}</div>
</template>

<script setup>
// Computed auto-updates, no nextTick needed
const itemCount = computed(() => state.items.length)

// Watcher auto-fires, no nextTick needed
watch(() => state.items, (newItems) => {
  console.log('Items changed:', newItems)
})
</script>

Top-level property assignments are synchronous:

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:

// βœ… Presenter pattern - always synchronous, no nextTick needed
presenter.items.push(newItem)
console.log(presenter.items)  // Immediately updated!

Error Handling

The bridge gracefully handles edge cases:

  • Uninitialized computed properties
  • Circular references
  • Failed setter operations
  • Missing dependencies

Performance Optimization

  • Intelligent change detection prevents unnecessary updates
  • Efficient shallow/deep equality checks
  • Minimal overhead for large object graphs

πŸ§ͺ Testing

npm test                 # Run tests
npm run test:watch      # Watch mode
npm run test:coverage   # Coverage report

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

πŸ“„ License

MIT Β© Visar Uruqi

πŸ”— Links

About

Once upon a time, there was a MobX object that wanted to live in Vue's reactive world. This bridge helps them understand each other by translating between their languages.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published