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:
- The design intent of
effectScopeand its relationship to Pinia store management - The factory pattern of
customRefand a complete debounced ref implementation - When to pair
triggerRefwithshallowRef - Two strategies for breaking out of reactivity:
toRawandmarkRaw - How Date, class instances, and RegExp behave at the boundaries of the reactive system
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):
track(): Call in getter — tells the reactive system to record the current effect as depending on this reftrigger(): Call in setter or elsewhere — tells the reactive system this ref's value changed; re-run all dependent effects
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:
- You're using
shallowReffor a large object and need to notify updates after internal mutation - 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:
- ECharts instance: thousands of properties and methods; deep proxy can take 50–200ms
- Three.js Scene: even more complex; some properties throw when proxied
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:
stop(fromParent)prevents redundant self-removal when parent scope is stopping all children- Removal from parent's
scopesarray 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:
- Large third-party library instances (ECharts, Three.js)
- Static configuration objects that never change
- 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
-
effectScopeis 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. -
customRefgives 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. -
shallowRef+triggerRefis 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. -
markRawis 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. -
The reactive system has clear boundaries: Custom class instances can be proxied;
DateandRegExpcannot.reactive()proxies deeply;shallowRef()only tracks the first layer. Understanding these boundaries is prerequisite to correctly choosing reactive APIs.