Chapter 2

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:


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:

  1. Mixin: merge component options together — most popular, most problematic
  2. Higher-Order Component (HOC): wrap a component, return an enhanced one — good for render functions
  3. Renderless Component: expose logic through slots, renders no UI
  4. 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:

  1. Options API would not be deprecated — both would permanently coexist
  2. setup() is optional — code not using Composition API is completely unaffected
  3. 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:


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:

Change 3: Lifecycle Hook Naming

In the original proposal, lifecycle hooks were called onCreated. They were unified with the on prefix, and beforeDestroyonBeforeUnmount, destroyedonUnmounted 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:

  1. Auto-register components: components imported in <script setup> don't need manual registration — the compiler automatically adds them to the components option
  2. Auto-expose bindings: top-level variables/functions automatically become accessible to the template, no return needed
  3. Type macro expansion: defineProps<{title: string}>(), defineEmits<{change: [string]}>(), withDefaults() — these "macros" that only exist in <script setup> are expanded into runtime code
  4. 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

  1. Mixin's three problems are structural — conventions cannot fix them: naming conflicts (merge strategy is opaque), unclear property origins (mysterious this properties), implicit dependencies (cross-mixin data dependencies invisible from code). These problems grow exponentially worse as project size increases.

  2. 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.

  3. 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.

  4. <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-sfc at compile time. The runtime sees a normal setup() function.

  5. 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.

Rate this chapter
4.6  / 5  (98 ratings)

💬 Comments