Birth of Composition API: RFC 0013, the Mixin Disaster and Community Debate
Chapter 2: The Birth of Composition API โ RFC 0013, the Mixin Disaster, and Community Conflict
Within 72 hours of going live in June 2019, RFC 0013 received over 300 comments on GitHub, the majority opposed. This was the most contentious API design decision in Vue's history โ and precisely why Composition API became what it is today.
Core Question: How bad were Mixins, really? What does Composition API solve that Mixin cannot?
After reading this chapter, you will understand:
- The three fundamental defects of Mixin, and why they become disasters in large codebases
- The critical differences between RFC 0013's original proposal and its final form, and how community feedback changed the design
- The essential difference between Composition API and React Hooks โ not at the syntax level, but at the execution model level
Level 1 ยท What You Need to Know (1-3 Years Experience)
2.1 Before Mixin, How Was Logic Reused?
Vue 2 had four logic-reuse mechanisms, ranked by popularity:
- Mixin: merge component options together โ most popular, most problematic
- Higher-Order Component (HOC): wrap a component, return an enhanced one โ good for render functions
- Renderless Component: expose logic through slots, renders no UI
- Plugin: mount global methods
Mixin was most popular because it was most intuitive: write reusable logic in an object, then inject it with mixins: [myMixin]. Looks simple.
// A mixin that seems reasonable
const mouseMixin = {
data() {
return {
x: 0,
y: 0
}
},
mounted() {
window.addEventListener('mousemove', this.onMouseMove);
},
beforeDestroy() {
window.removeEventListener('mousemove', this.onMouseMove);
},
methods: {
onMouseMove(event) {
this.x = event.clientX;
this.y = event.clientY;
}
}
};
// Usage
export default {
mixins: [mouseMixin],
// Now this.x and this.y are available
template: `<div>Mouse: {{ x }}, {{ y }}</div>`
}
This code looks fine in isolation. The problems start when you use multiple mixins.
2.2 The Three Mixin Disasters
Disaster 1: Naming Conflicts
const userMixin = {
data() {
return {
loading: false, // "user data loading"
userData: null
}
},
methods: {
async fetchUser() {
this.loading = true; // sets userMixin's loading
}
}
};
const postMixin = {
data() {
return {
loading: false, // "post data loading"
postData: null
}
},
methods: {
async fetchPost() {
this.loading = true; // WHICH loading?!
}
}
};
export default {
mixins: [userMixin, postMixin],
// There is only ONE this.loading
// When fetchUser sets loading=true, the post loading indicator also disappears
}
Vue 2's mixin merge strategy for data: component takes priority, and between two mixins, same-named properties merge with the later mixin winning (dependent on declaration order). This behavior is hard to predict and extremely difficult to debug in large projects.
Disaster 2: Unclear Property Origins
export default {
mixins: [authMixin, permissionMixin, pageMixin, formMixin, notificationMixin],
methods: {
handleSubmit() {
if (this.isAuthenticated) { // Which mixin is this from?
if (this.hasPermission('write')) { // And this?
this.saveForm(); // Component or mixin?
this.showNotification('Saved!'); // And this?
this.trackPageEvent('form_submit'); // And this?
}
}
}
}
}
When reading this code, this.isAuthenticated, this.hasPermission, this.showNotification could each come from any mixin, or be defined on the component itself. You have to open each mixin file one by one to find out. In components with 10+ mixins, this "mystery property scavenger hunt" consumes enormous amounts of developer time.
Disaster 3: Implicit Dependencies
const paginationMixin = {
data() {
return {
currentPage: 1,
pageSize: 20
}
},
computed: {
paginatedData() {
// Note: paginationMixin depends on this.allData
// But allData is defined in dataMixin!
return this.allData.slice(
(this.currentPage - 1) * this.pageSize,
this.currentPage * this.pageSize
);
}
}
};
const dataMixin = {
data() {
return {
allData: [] // paginationMixin implicitly depends on this
}
}
};
// Only works when BOTH mixins are used together
// Using paginationMixin alone causes this.allData to be undefined
export default {
mixins: [dataMixin, paginationMixin] // Order matters too!
}
This implicit dependency is the hardest maintenance problem with mixins: you cannot see from paginationMixin's code that it depends on allData, unless you already know it's always used alongside dataMixin. Without sufficient documentation, this coupling is completely hidden.
2.3 The Storm Unleashed by RFC 0013
In June 2019, Evan You submitted RFC-0013 to the GitHub vuejs/rfcs repository, titled "Function-based Component API." This was the original form of Composition API.
The RFC centered on a setup() function โ executed before the component instance is created โ that returns reactive state and functions the component can use:
// RFC 0013's original proposal (simplified)
export default {
setup() {
const count = value(0); // Called value() then, later renamed to ref()
function increment() {
count.value++;
}
return { count, increment };
}
}
Community opposition focused on three points:
Objection 1: "How is this different from React Hooks?" "Vue is becoming React. If I wanted functional programming, I'd use React."
This objection reflected genuine concern: one of Vue's core value propositions was friendliness to beginners. Options API's structure was clear โ each option (data/methods/computed/watch) had a defined responsibility. Composition API broke that structure apart.
Objection 2: "Breaking Change" "I have a large Vue 2 codebase. This means I have to rewrite everything."
This concern was based on a misunderstanding, but it was extremely widespread. The RFC was not deprecating Options API, but that point was insufficiently clear in the original RFC text.
Objection 3: "Steeper Learning Curve"
"Vue's advantage is its ease of entry. Now we need to understand setup(), ref(), reactive(), computed(), new lifecycle function syntax... this is too hostile to newcomers."
Evan You did extensive explanatory work in the community discussions. The final design crystallized around several key commitments:
- Options API would not be deprecated โ both would permanently coexist
setup()is optional โ code not using Composition API is completely unaffected- Both styles can coexist in the same component (though not recommended)
2.4 The Birth of <script setup>
After Composition API's official release, Evan You noticed that setup() was still somewhat verbose: you had to write the setup() function, define variables inside it, then return an object.
In 2021, RFC-0227 proposed <script setup> syntax โ syntactic sugar for Composition API:
<!-- Standard Composition API -->
<script>
import { ref, computed } from 'vue';
export default {
setup() {
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubled, increment };
}
}
</script>
<!-- Equivalent <script setup> -->
<script setup>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
// No return needed โ top-level variables/functions are automatically exposed to template
</script>
The advantages of <script setup> go beyond saving a few lines:
- The compiler can apply more aggressive optimizations (because it knows the origin of all variables)
defineProps/defineEmitshave complete TypeScript type inference- No extra closure overhead (the object returned by
setup()is an additional closure layer)
Level 2 ยท How It Works Under the Hood (3-5 Years Experience)
2.5 The Complete Rules of Mixin Merge Strategy
Understanding mixin merge strategy is key to understanding why it's so hard to maintain:
Mixin merge priority rules:
Component itself > later-declared mixin > earlier-declared mixin
mixins: [A, B, C]
โlowest priority โhighest priority (below component)
Merge rules vary by option type:
data: Deep merge, component takes priority, same-named prop overwrites mixin
methods: Same-named: component overwrites mixin, between mixins: later overwrites earlier
computed: Same-named: component overwrites mixin
watch: MERGED into array, ALL watchers execute (NOT overwritten!)
lifecycle: MERGED into array, ALL hooks execute, mixins run before component
components: Merged, component takes priority
directives: Merged, component takes priority
The most bug-prone is the watch merge rule: when two mixins both watch the same property, both handlers execute, and execution order depends on mixin declaration order. This is completely different from the methods overwrite behavior for naming conflicts โ very easy to confuse.
2.6 Composition API Execution Timing
The exact position of setup() in the component instantiation process:
Component instantiation flow (simplified):
1. createApp().mount()
โ
2. Parse component options
โ
3. beforeCreate hook (Options API)
โ
4. Initialize reactive data (Options API)
โ
5. Execute setup() โโโ Composition API runs here
โ โข props are already reactive at this point
โ โข emit is available
โ โข this is NOT available (no this in setup!)
โ
6. created hook (Options API)
โ
7. Template compilation โ render function
โ
8. beforeMount โ mounted
The absence of this in setup() is one of the most important design decisions. this in Options API was a container for everything โ and the source of TypeScript type inference nightmares. Remove this, and all variable origins become explicit: from setup()'s parameters, from import, or defined inside setup().
export default {
props: {
userId: String
},
setup(props, context) {
// props: reactive, do NOT destructure (loses reactivity)
// context: { attrs, slots, emit, expose }, non-reactive, CAN destructure
const { emit } = context; // Safe to destructure
// const { userId } = props; // DON'T do this! Loses reactivity
// Correct usage of props
const user = computed(() => fetchUser(props.userId)); // Reactive
// No this!
// this.emit โ emit (from context)
// this.userId โ props.userId
return { user };
}
}
2.7 Composition API vs React Hooks: The Essential Execution Model Difference
This is the most commonly misunderstood comparison:
React Hooks execution model:
render 1: [useState=0] [useEffect=fn1] [useMemo=42]
โ
state changes, re-render
โ
render 2: [useState=1] [useEffect=fn2] [useMemo=42]
Every render: hooks re-execute in order
Hooks must be called at top level (not inside if/for)
Stale closure problem: fn1 captures values from render 1
Vue setup() execution model:
setup() executes ONCE:
โ
โโโ const count = ref(0) โ creates reactive ref (persists permanently)
โโโ const doubled = computed(...) โ creates computed (persists permanently)
โโโ watchEffect(...) โ registers effect (persists permanently)
โโโ return { count, doubled } โ exposes to template
Subsequent updates:
- Template's render function re-executes
- setup() itself does NOT re-execute
- ref/computed/effect internals still respond to changes
This difference leads to completely different programming patterns:
In React, scenarios requiring dependency arrays:
// React
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Must declare dependency, or won't re-fetch when userId changes
const greeting = useMemo(() => {
return `Hello, ${user?.name}`;
}, [user]); // Must declare dependency, or recalculates on every render
return <div>{greeting}</div>;
}
Vue Composition API equivalent:
// Vue
export default {
props: ['userId'],
setup(props) {
const user = ref(null);
// watch automatically tracks props.userId โ no dependency array
watch(() => props.userId, async (userId) => {
user.value = await fetchUser(userId);
}, { immediate: true });
// computed automatically tracks user.value โ no dependency array
const greeting = computed(() => `Hello, ${user.value?.name}`);
return { user, greeting };
}
}
Vue doesn't need dependency arrays because: the reactivity system automatically collects dependencies when an effect executes (whichever reactive values are read become dependencies). You don't manually declare them. This is the most fundamental difference between Vue's fine-grained tracking model and React's immutable data model.
2.8 Composable Design Patterns
Composables (composable functions) are the unit of logic reuse in Composition API, similar to React's custom Hooks. But because Vue's reactivity system doesn't need to re-execute on every render, composables can be designed more freely than Hooks:
// useMouse.js โ standard composable pattern
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function onMouseMove(event) {
x.value = event.clientX;
y.value = event.clientY;
}
// Lifecycle hooks can be used directly inside composables
// They are registered on the currently running component instance
onMounted(() => window.addEventListener('mousemove', onMouseMove));
onUnmounted(() => window.removeEventListener('mousemove', onMouseMove));
return { x, y };
}
// Usage in <script setup>
import { useMouse } from './useMouse';
const { x, y } = useMouse(); // Destructuring is safe because x and y are refs
// Compare to reactive: const { x } = reactive({x: 0}) would lose reactivity
Key advantages over Mixin:
Comparison Mixin Composable
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Property origin Unclear (mysterious this) Clear (visible in destructuring)
Naming conflicts Auto-merged (silent) Manual rename (explicit control)
Implicit deps Exist (mixin coupling) None (explicit parameter passing)
TypeScript Poor type inference Full type inference
Nesting Not supported Supported (composables call composables)
Testing Hard (needs full component) Simple (pure function testing)
// Mixin: naming conflicts unavoidable
export default {
mixins: [useMouseMixin, useScrollMixin],
// If both mixins have x/y, silent conflict
}
// Composable: rename to resolve conflicts
// <script setup>
const { x: mouseX, y: mouseY } = useMouse();
const { x: scrollX, y: scrollY } = useScroll();
// Completely clear, no conflict
Level 3 ยท Design Documents and Source Code (Senior Developers)
2.9 The Evolution from RFC 0013 to Final Implementation
Between RFC 0013's original version (June 2019) and Vue 3's official release (September 2020), 14 months of iteration produced several important API changes:
Change 1: value() โ ref()
The original RFC used a value() function to create reactive references, later renamed to ref(). The reason: value already has established meaning as a property name (accessing a ref's value uses .value), using it as a function name created confusion.
Change 2: Precise Definition of Template Auto-Unwrapping Scope
In the initial design, refs used in templates didn't need .value, but whether refs nested inside reactive objects auto-unwrapped was disputed. The final rules:
- Refs at the top level of a template: auto-unwrap
- Refs inside reactive objects: auto-unwrap
- Refs inside arrays: do NOT auto-unwrap (for performance and consistency)
Change 3: Lifecycle Hook Naming
In the original proposal, lifecycle hooks were called onCreated. They were unified with the on prefix, and beforeDestroy โ onBeforeUnmount, destroyed โ onUnmounted were renamed โ semantically consistent with component options but with different names.
2.10 The Compilation Mechanics of <script setup>
<script setup> is a compile-time feature, not a runtime feature. Vue's compiler (@vue/compiler-sfc) transforms the <script setup> block into a normal setup() function when compiling .vue files:
<!-- Source code -->
<script setup>
import { ref } from 'vue';
import MyComponent from './MyComponent.vue';
const props = defineProps({
title: String
});
const count = ref(0);
function increment() {
count.value++;
}
</script>
Compiled output (roughly equivalent to):
// Compiled output (simplified)
import { ref, defineComponent } from 'vue';
import MyComponent from './MyComponent.vue';
export default defineComponent({
props: {
title: String // defineProps compiled here
},
components: {
MyComponent // Imported components automatically registered
},
setup(props) {
const count = ref(0);
function increment() {
count.value++;
}
// All top-level bindings auto-exposed
return { count, increment };
// Note: props doesn't need to be returned โ it's already in the outer scope
}
});
The compiler does several things the runtime cannot:
- Auto-register components: components imported in
<script setup>don't need manual registration โ the compiler automatically adds them to thecomponentsoption - Auto-expose bindings: top-level variables/functions automatically become accessible to the template, no
returnneeded - Type macro expansion:
defineProps<{title: string}>(),defineEmits<{change: [string]}>(),withDefaults()โ these "macros" that only exist in<script setup>are expanded into runtime code - Performance optimization: the compiler knows all top-level bindings and can generate more precise PatchFlags
2.11 The Type System Implementation of defineProps
defineProps has two calling styles with completely different type system implementations:
// Style 1: Runtime declaration (Vue 2-style, has runtime type checking)
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
});
// Style 2: Type declaration (TypeScript-style, compile-time type checking, no runtime checking)
interface Props {
title: string;
count?: number;
}
const props = defineProps<Props>();
// Or add defaults with withDefaults:
const props = withDefaults(defineProps<Props>(), {
count: 0
});
Style 2's implementation is purely compile-time: @vue/compiler-sfc analyzes the type argument in defineProps<Props>() and converts the TypeScript type into a runtime prop definition:
// Type information extracted by the compiler (packages/compiler-sfc/src/script/defineProps.ts)
// interface Props { title: string; count?: number; }
// Converted to:
// props: { title: { type: String, required: true }, count: { type: Number } }
This conversion process is implemented in packages/compiler-sfc/src/script/resolveType.ts, which supports recursive TypeScript type resolution including union types, intersection types, generics, and more.
2.12 SSR Safety Boundaries in Composables
In SSR (Server-Side Rendering) scenarios, composables have a critical boundary to be aware of: onMounted and onUnmounted do not execute on the server.
// In SSR, this code's behavior:
export function useMouse() {
const x = ref(0);
onMounted(() => {
// Server: this never executes
window.addEventListener('mousemove', handler); // window doesn't exist in Node.js!
});
return { x };
}
This is actually correct behavior โ in SSR, you don't need mouse tracking. But if you access browser APIs at the top level of a composable:
export function useMouse() {
const x = ref(0);
// DANGER: will throw ReferenceError: window is not defined on the server
window.addEventListener('mousemove', handler);
return { x };
}
Correct SSR-safe approach:
export function useMouse() {
const x = ref(0);
// Option 1: inside onMounted (doesn't execute on server)
onMounted(() => {
window.addEventListener('mousemove', handler);
});
// Option 2: runtime detection
if (typeof window !== 'undefined') {
window.addEventListener('mousemove', handler);
}
return { x };
}
Level 4 ยท Edge Cases and Traps (Everyone Should Read)
Trap 1: Calling Composable Lifecycle Hooks Outside setup
// Wrong!
const { x, y } = useMouse(); // Called outside setup
export default {
setup() {
// At this point, onMounted/onUnmounted inside useMouse
// are registered to the wrong context (null)
}
}
// Correct: always call composables inside setup()
export default {
setup() {
const { x, y } = useMouse(); // Lifecycle hooks register to current component instance
return { x, y };
}
}
Root cause: Vue maintains a global variable currentInstance pointing to the component instance currently running setup(). When lifecycle hooks like onMounted are called, they read this global variable and register the handler to the corresponding instance. If a composable is called after setup() has finished, currentInstance is already null โ the lifecycle hook registration is silently ignored (or prints a warning in development mode).
Trap 2: async setup and Suspense
// This is dangerous!
export default {
async setup() {
const data = await fetch('/api/data').then(r => r.json());
// โ ๏ธ Problem: after await, currentInstance no longer points to the current component
// The following lifecycle hook registration will fail:
onMounted(() => {
console.log('mounted'); // This may not execute!
});
return { data };
}
}
// Correct: move lifecycle hooks before the await
export default {
async setup() {
// Register lifecycle hooks BEFORE await
onMounted(() => {
console.log('mounted'); // Executes correctly
});
// Then await
const data = await fetch('/api/data').then(r => r.json());
return { data };
}
}
Root cause: JavaScript's async/await is Promise-based. Code after an await executes in the microtask queue โ at that point, the current component's setup() has already "returned" (returning a Promise), and currentInstance has been reset to null. Vue 3 handles async setup with the <Suspense> component, but the lifecycle hook timing issue remains.
Trap 3: Execution Timing Differences Between watch and watchEffect
import { ref, watch, watchEffect, nextTick } from 'vue';
const count = ref(0);
// watchEffect: executes immediately, then responds to changes
watchEffect(() => {
console.log('watchEffect:', count.value);
});
// Immediately prints: watchEffect: 0
// watch: does NOT execute immediately by default, only responds to changes
watch(count, (newVal) => {
console.log('watch:', newVal);
});
// Does not print immediately
count.value = 1;
// watchEffect prints: watchEffect: 1 (next tick)
// watch prints: watch: 1 (next tick)
// IMPORTANT: neither executes synchronously!
count.value = 2;
// Neither callback has executed yet
console.log('sync'); // This prints first
// Then in batch: watchEffect: 2, watch: 2
// The intermediate value 1 (from 0 โ 1 โ 2) is SKIPPED
Trap: modifying the watched value inside a watch callback
const count = ref(0);
// Infinite loop!
watch(count, (newVal) => {
count.value = newVal + 1; // modifies count โ triggers watch โ modifies count โ ...
});
Vue has a maximum recursion depth detection (default 100 iterations), after which it prints a warning and stops. But the design-level problem remains.
Trap 4: Reactivity of defineProps in <script setup>
// <script setup>
const props = defineProps(['title', 'count']);
// Wrong: destructuring props loses reactivity!
const { title, count } = props;
// title and count are now plain string/number, they don't follow prop updates
// Correct: use props.xxx directly
console.log(props.title); // Reactive โ re-evaluates when props update
// Or use toRefs
import { toRefs } from 'vue';
const { title, count } = toRefs(props);
// title.value and count.value are refs that follow prop updates
Root cause: The props object itself is reactive (a Proxy). Destructuring extracts primitive values that lose their connection to the Proxy. toRefs creates a ref for each property whose getter internally accesses props.xxx, maintaining the connection to the Proxy.
Chapter Summary
-
Mixin's three problems are structural โ conventions cannot fix them: naming conflicts (merge strategy is opaque), unclear property origins (mysterious
thisproperties), implicit dependencies (cross-mixin data dependencies invisible from code). These problems grow exponentially worse as project size increases. -
RFC 0013's community debate changed the final API design: the community's opposition prompted the Vue team to explicitly preserve Options API, making Composition API an opt-in addition rather than a replacement. The 14 months of subsequent iteration refined the boundary details.
-
The essential difference between Composition API and React Hooks is the execution model:
setup()executes once, the reactivity system auto-tracks dependencies, no dependency arrays needed. React Hooks re-execute every render, with manually declared dependencies. This is not a syntax preference โ it is two completely different mental models. -
<script setup>is a compile-time feature, not a runtime one: its type macros (defineProps<T>()), automatic component registration, and automatic binding exposure are all completed by@vue/compiler-sfcat compile time. The runtime sees a normalsetup()function. -
Composables completely solve Mixin's three problems: variable origins are clear (visible in destructuring assignments), naming conflicts can be manually renamed (explicit control), dependencies are passed as parameters (explicit declaration). These three advantages are worth far more than syntactic improvements in large projects.