Chapter 9

effectScope, customRef, triggerRef: Design Intent Behind Advanced Reactivity APIs

Chapter 9: effectScope, customRef, triggerRef โ€” The Design Intent of Advanced Reactive APIs

Every Pinia store internally uses effectScope() โ€” it's not a nice-to-have API; it's the infrastructure that enables complex state management libraries to work safely with Vue 3's reactive system.

Core Question: What do you do when you need to "stop all watchers at once"? When should you have complete control over track/trigger timing? Why must ECharts instances be wrapped with markRaw?

After Reading This Chapter, You Will Understand:


Level 1 ยท What You Need to Know (1โ€“3 Years Experience)

effectScope: Batch Lifecycle Management for Effects

Inside a Vue 3 component, effects created by watch, watchEffect, and computed are automatically bound to the component's lifecycle โ€” they clean up when the component unmounts. But when using these APIs outside a component (in state management libraries, utility functions), you need to manage their lifecycle manually.

effectScope provides an elegant solution:

import { effectScope, ref, watch, computed, onScopeDispose } from 'vue'

const scope = effectScope()

scope.run(() => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)
  
  watch(count, (val) => {
    console.log('count changed:', val)
  })
  
  watchEffect(() => {
    console.log('doubled:', doubled.value)
  })
  
  // Cleanup hook โ€” fires when scope stops
  onScopeDispose(() => {
    console.log('scope stopped, running cleanup')
  })
})

// Stop ALL effects at once
scope.stop()
// All watch, computed, watchEffect instances are stopped
// onScopeDispose callback fires

Core value of effectScope: No need to maintain an array of stop functions manually โ€” one scope.stop() handles all cleanup.

Nested Scopes and Detached Mode

const parentScope = effectScope()

parentScope.run(() => {
  // Child scope (default: stops when parent stops)
  const childScope = effectScope()
  childScope.run(() => {
    watchEffect(() => console.log('child effect'))
  })
  
  // Detached scope (independent from parent)
  const detachedScope = effectScope(true)   // true = detached
  detachedScope.run(() => {
    watchEffect(() => console.log('detached effect'))
  })
})

parentScope.stop()
// 'child effect' watcher is stopped
// 'detached effect' watcher keeps running

Use cases for detached scope: Global modals, notification systems, and other side effects that need to outlive any particular component's lifecycle.

customRef: Total Control Over track/trigger

customRef lets you create a ref where you control exactly when dependencies are tracked and when updates are triggered:

import { customRef } from 'vue'

function useDebouncedRef<T>(value: T, delay = 300) {
  let timer: ReturnType<typeof setTimeout>
  
  return customRef((track, trigger) => {
    return {
      get() {
        track()          // Tell the reactive system: "track this ref as a dependency"
        return value
      },
      set(newValue: T) {
        clearTimeout(timer)
        timer = setTimeout(() => {
          value = newValue
          trigger()      // Tell the reactive system: "this ref changed, re-run dependents"
        }, delay)
      }
    }
  })
}

// Usage
const searchQuery = useDebouncedRef('', 300)

watch(searchQuery, (query) => {
  fetchSearchResults(query)
})
// Multiple inputs within 300ms fire only one search

The factory function receives (track, trigger):

triggerRef and shallowRef

shallowRef is the shallow variant of ref โ€” it only tracks whether .value itself is replaced, not changes to inner properties:

import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({ count: 0, name: 'Vue' })

// โœ“ Triggers update (replaces the entire .value)
state.value = { count: 1, name: 'Vue 3' }

// โŒ Does NOT trigger update (mutates inner property โ€” shallowRef doesn't track this)
state.value.count = 99

// โœ… Mutate inner property then manually trigger
state.value.count = 99
triggerRef(state)   // Manually trigger all effects depending on state

Use cases for triggerRef:

  1. You're using shallowRef for a large object and need to notify updates after internal mutation
  2. Integrating with third-party libraries (like ECharts) where the library mutates state internally

toRaw and markRaw: Escaping Reactivity

import { reactive, toRaw, markRaw } from 'vue'

// toRaw: get the raw object behind a reactive proxy (temporarily escape reactivity)
const state = reactive({ count: 0 })
const rawState = toRaw(state)

rawState.count = 100     // Mutates the raw object โ€” no Vue reactivity triggered
console.log(state.count)    // 0 (reactive proxy wasn't triggered)
console.log(rawState.count) // 100

// markRaw: permanently mark an object as non-reactive
import * as echarts from 'echarts'

const chartInstance = markRaw(echarts.init(document.getElementById('chart')))

// Now safe to put inside reactive state
const appState = reactive({
  chart: chartInstance   // Will NOT be wrapped in Proxy
})

Level 2 ยท How It Actually Works (3โ€“5 Years Experience)

effectScope Internal Mechanics

The core idea of effectScope is tracking all effects created inside its run callback. When the scope stops, it batch-calls stop() on all of them.

Internal data structure:

EffectScope
โ”œโ”€โ”€ active: boolean              // Whether scope is active
โ”œโ”€โ”€ effects: ReactiveEffect[]    // All effects inside the scope
โ”œโ”€โ”€ cleanups: (() => void)[]     // Callbacks registered via onScopeDispose
โ”œโ”€โ”€ parent: EffectScope | undefined  // Parent scope
โ””โ”€โ”€ scopes: EffectScope[]        // Child scopes

Effect registration flow:

effectScope.run(() => { ... })
        โ”‚
        โ–ผ
activeEffectScope = this
        โ”‚
        โ–ผ
User creates a ReactiveEffect inside callback
        โ”‚
        โ–ผ
ReactiveEffect constructor checks activeEffectScope
        โ”‚
        โ–ผ
Appends itself to activeEffectScope.effects
        โ”‚
        โ–ผ
activeEffectScope = parent (restored)

scope.stop() flow:

scope.stop()
  โ”‚
  โ”œโ”€โ–บ Iterate this.effects โ†’ call effect.stop() for each
  โ”‚
  โ”œโ”€โ–บ Iterate this.scopes โ†’ call childScope.stop(true) for each
  โ”‚
  โ””โ”€โ–บ Iterate this.cleanups โ†’ call each cleanup()

How Pinia Uses effectScope

Pinia is the canonical example of effectScope in production. Each Pinia store gets its own effectScope:

// Simplified from pinia source (packages/pinia/src/store.ts)
function createSetupStore(id, setup, options, pinia) {
  // Create a dedicated effectScope for this store
  const scope = pinia._e.run(() => {
    const scope = effectScope()
    return scope
  })
  
  // Run the setup function inside the scope
  const setupStore = scope.run(() => setup())
  
  // When the store is disposed, stop all its effects
  store.$dispose = () => {
    scope.stop()
    // ... other cleanup
  }
  
  return store
}

Calling store.$dispose() automatically stops all watch, computed, and watchEffect instances inside that store โ€” no manual tracking needed.

How the Debounced customRef Works (Step by Step)

User types "V"  โ†’  searchQuery.value = "V"
                        โ”‚
                        โ–ผ
              set() is called
                        โ”‚
                        โ–ผ
              clearTimeout(timer)   โ†’ clear old timer
              timer = setTimeout(() => {
                value = "V"
                trigger()    โ† fires after 300ms
              }, 300)
                        โ”‚
              (types "Vu" within 300ms)
                        โ”‚
                        โ–ผ
              set() called again
              clearTimeout(timer)   โ†’ 300ms timer CANCELLED
              timer = setTimeout(() => {
                value = "Vu"
                trigger()    โ† new 300ms timer
              }, 300)
                        โ”‚
              (continues typing "Vue")
                        โ”‚
              ... same pattern ...
                        โ”‚
              (no new input for 300ms)
                        โ”‚
                        โ–ผ
              trigger() fires
              All effects depending on searchQuery re-run
              watch(searchQuery, ...) callback fires
              fetchSearchResults("Vue")

Throttled ref implementation:

function useThrottledRef<T>(value: T, interval = 500) {
  let lastTime = 0
  
  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue: T) {
      const now = Date.now()
      if (now - lastTime >= interval) {
        lastTime = now
        value = newValue
        trigger()
      }
      // Updates within interval are silently dropped
    }
  }))
}

Persistent ref (auto-syncs to localStorage):

function useLocalStorageRef<T>(key: string, initialValue: T) {
  const stored = localStorage.getItem(key)
  let value: T = stored ? JSON.parse(stored) : initialValue
  
  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue: T) {
      value = newValue
      localStorage.setItem(key, JSON.stringify(newValue))
      trigger()
    }
  }))
}

const theme = useLocalStorageRef('theme', 'light')
theme.value = 'dark'   // auto-persists to localStorage

shallowRef vs ref: The Depth Trade-off

ref({ count: 0, nested: { deep: 1 } })
โ”œโ”€โ”€ .value โ†’ Proxy { count: 0, nested: Proxy { deep: 1 } }
โ””โ”€โ”€ ALL nested properties are proxied

shallowRef({ count: 0, nested: { deep: 1 } })
โ”œโ”€โ”€ .value โ†’ the raw object (NOT proxied)
โ””โ”€โ”€ Only .value assignment itself is tracked

For large third-party objects, deep proxying can be expensive:

Using shallowRef avoids this entirely.


Level 3 ยท Design Docs and Source Code (Senior Developers)

effectScope Source Code

File: packages/reactivity/src/effectScope.ts

export class EffectScope {
  private _active = true
  effects: ReactiveEffect[] = []
  cleanups: (() => void)[] = []
  parent: EffectScope | undefined
  scopes: EffectScope[] | undefined
  private index: number | undefined

  constructor(public detached = false) {
    this.parent = activeEffectScope
    if (!detached && activeEffectScope) {
      // Non-detached: register with parent scope
      this.index =
        (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(this) - 1
    }
  }

  run<T>(fn: () => T): T | undefined {
    if (this._active) {
      const currentEffectScope = activeEffectScope
      try {
        activeEffectScope = this   // Set current scope
        return fn()                // Run callback; effects created here auto-register
      } finally {
        activeEffectScope = currentEffectScope   // Restore
      }
    }
  }

  stop(fromParent?: boolean) {
    if (this._active) {
      let i, l
      for (i = 0, l = this.effects.length; i < l; i++) {
        this.effects[i].stop()
      }
      for (i = 0, l = this.cleanups.length; i < l; i++) {
        this.cleanups[i]()
      }
      if (this.scopes) {
        for (i = 0, l = this.scopes.length; i < l; i++) {
          this.scopes[i].stop(true)
        }
      }
      // Remove self from parent scope to prevent memory leaks
      if (!this.detached && this.parent && !fromParent) {
        const last = this.parent.scopes!.pop()
        if (last && last !== this) {
          this.parent.scopes![this.index!] = last
          last.index = this.index!
        }
      }
      this.parent = undefined
      this._active = false
    }
  }
}

Design highlights:

  1. stop(fromParent) prevents redundant self-removal when parent scope is stopping all children
  2. Removal from parent's scopes array uses "swap with last element" โ€” O(1) instead of O(n)

customRef Source

File: packages/reactivity/src/ref.ts

export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  return new CustomRefImpl(factory) as any
}

class CustomRefImpl<T> {
  public dep?: Dep = undefined
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']
  public readonly __v_isRef = true

  constructor(factory: CustomRefFactory<T>) {
    const { get, set } = factory(
      () => trackRefValue(this),     // track function
      () => triggerRefValue(this),   // trigger function
    )
    this._get = get
    this._set = set
  }

  get value() {
    return this._get()
  }

  set value(newVal) {
    this._set(newVal)
  }
}

The implementation is elegantly minimal โ€” it wraps trackRefValue and triggerRefValue as track/trigger for the user, reusing all of the regular ref's tracking infrastructure.

How markRaw Works Under the Hood

// packages/reactivity/src/reactive.ts

export function markRaw<T extends object>(value: T): Raw<T> {
  // Sets __v_skip = true on the object
  def(value, ReactiveFlags.SKIP, true)
  return value
}

// During reactive() / readonly() Proxy creation:
function createReactiveObject(target, ...) {
  // If __v_skip is set, skip Proxy creation entirely
  if (target[ReactiveFlags.SKIP] || !canObserve(target)) {
    return target   // return the raw object as-is
  }
  // ... create Proxy
}

markRaw simply sets __v_skip: true on the object. The reactive system checks this flag and bypasses Proxy creation entirely.

How Vue Handles Date, RegExp, class Instances

// packages/reactivity/src/reactive.ts

function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON      // Can be proxied
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION  // Special collection handling
    default:
      return TargetType.INVALID     // Not proxied (Date, RegExp, etc.)
  }
}

toRawType uses Object.prototype.toString.call(value). Custom class instances return "Object" โ€” so they can be proxied by reactive(). But Date returns "Date" and RegExp returns "RegExp", both mapping to INVALID.

class User {
  constructor(public name: string) {}
}

const user = reactive(new User('Vue'))
user.name = 'React'   // โœ“ triggers updates

const date = reactive(new Date())
// date โ†’ "Date" โ†’ TargetType.INVALID โ†’ reactive() returns raw Date unchanged
// Mutations via date.setMonth() etc. won't trigger any Vue reactivity

Level 4 ยท Edge Cases and Traps (All Experience Levels)

Trap 1: Refs Created Outside scope.run() Are Not Managed by the Scope

const count = ref(0)   // Created OUTSIDE scope.run()

const scope = effectScope()
scope.run(() => {
  watch(count, (val) => console.log(val))
  // โœ“ This watch effect IS managed by scope โ€” scope.stop() will stop it
})

scope.stop()           // The watch is stopped
count.value = 100      // No reaction (watcher is gone)
// But `count` itself still exists and holds its value

Key insight: scope manages effects (watch, watchEffect, computed), not reactive data (ref, reactive). scope.stop() stops the observation behavior, not the data itself.

Trap 2: Forgetting track() in customRef Breaks Dependency Collection

// โŒ Wrong: no track() in getter
const badRef = customRef((track, trigger) => ({
  get() {
    // Missing track()!
    return value
  },
  set(newValue) {
    value = newValue
    trigger()
  }
}))

// Result: watch(badRef, ...) NEVER fires!
// Without track(), no effect ever registers as depending on this ref
watch(badRef, (val) => {
  console.log(val)  // Never prints
})

Correct implementation: Always call track() inside the getter, on every invocation.

Trap 3: shallowRef Deep Mutations Don't Update the View

const config = shallowRef({
  theme: 'light',
  fontSize: 14,
  nested: { color: 'blue' }
})

// โŒ Does NOT trigger update
config.value.theme = 'dark'
config.value.nested.color = 'red'

// โœ… Option 1: Replace the entire .value
config.value = { ...config.value, theme: 'dark' }

// โœ… Option 2: Mutate then manually triggerRef
config.value.theme = 'dark'
triggerRef(config)

This is a common source of confusion when someone treats shallowRef like a regular ref.

Trap 4: markRaw Is Permanent and Cannot Be Undone

const obj = { name: 'data', value: 42 }
markRaw(obj)   // Sets __v_skip = true on obj

const reactiveObj = reactive(obj)
// reactive() sees __v_skip and returns obj unchanged
console.log(reactiveObj === obj)   // true

reactiveObj.value = 99   // No reactive update โ€” it's just the raw object

Practical guideline: Use markRaw for:

  1. Large third-party library instances (ECharts, Three.js)
  2. Static configuration objects that never change
  3. Function references (functions shouldn't be proxied anyway)

Never use markRaw on data that needs reactive tracking.

Trap 5: Date Objects Inside reactive() Don't Track Method Mutations

const state = reactive({
  date: new Date('2024-01-01')
})

// โš ๏ธ Does NOT trigger an update
state.date.setMonth(5)   // Mutates Date internal state โ€” not tracked by reactive

// โœ… Replacing the property triggers an update
state.date = new Date('2024-06-01')

Root cause: Vue 3 maps Date to TargetType.INVALID โ€” no Proxy is created, so method calls on the Date object are not intercepted. To track date changes reactively, either replace the entire Date instance, or use a timestamp ref: const ts = ref(Date.now()).


Chapter Summary

  1. effectScope is the lifecycle management infrastructure of the reactive system: It solves "how to batch-stop a group of effects" โ€” the mechanism that enables libraries like Pinia to safely use Vue reactive APIs outside of components.

  2. customRef gives you complete control over track/trigger: Debouncing, throttling, localStorage persistence โ€” any scenario requiring custom "when to respond" logic can be built with customRef while staying inside Vue's reactive model.

  3. shallowRef + triggerRef is the right tool for large objects: For ECharts, Three.js, and other large library instances, shallowRef avoids the deep-proxy performance penalty; triggerRef provides an escape hatch for manual update notification.

  4. markRaw is unidirectional and permanent: Once marked, an object can never be proxied by Vue's reactive system. Use it for objects that genuinely don't need reactivity โ€” but understand there's no way to undo it.

  5. The reactive system has clear boundaries: Custom class instances can be proxied; Date and RegExp cannot. reactive() proxies deeply; shallowRef() only tracks the first layer. Understanding these boundaries is prerequisite to correctly choosing reactive APIs.

Rate this chapter
4.7  / 5  (40 ratings)

๐Ÿ’ฌ Comments