Vue Composables vs OOP in JavaScript: When to Use Which
In Vue (and modern frontend in general), you can structure logic in two main ways: composables (functions that return reactive state and methods, composition-based) and OOP (classes with instance state and methods, object-oriented). Both are valid; they emphasize different concerns and scale differently. This post explains both, with code, pros and cons, and when to reach for each.
What "composable" and "OOP" mean here
Composable (Vue) here means: a function that uses the Composition API (ref, reactive, computed, watch, etc.) and returns an object of reactive state and functions. No this, no classes—just composition of small functions. The same composable can be used in multiple components; state is created per call (per component instance).
OOP here means: a class that holds state (fields) and behavior (methods). You new it (or use a factory), and each instance has its own state. Fits dependency injection, services, domain models, and patterns that rely on encapsulation and inheritance.
Same problem can often be solved with either style; the tradeoffs are in testability, reuse, mental model, and how well they fit the rest of your stack.
Example domain: a counter with history
We'll implement a counter that keeps a history of values, in both styles.
Composable style
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const history = ref<number[]>([initial])
const increment = () => {
count.value++
history.value = [...history.value, count.value]
}
const decrement = () => {
count.value--
history.value = [...history.value, count.value]
}
const reset = () => {
count.value = initial
history.value = [initial]
}
const canUndo = computed(() => history.value.length > 1)
const undo = () => {
if (!canUndo.value) return
history.value = history.value.slice(0, -1)
count.value = history.value[history.value.length - 1]
}
return {
count,
history,
increment,
decrement,
reset,
canUndo,
undo,
}
}
Usage in a component:
// In setup (or <script setup>)
const { count, increment, decrement, reset, canUndo, undo } = useCounter(10)
Each component that calls useCounter() gets its own count and history. No shared mutable singleton unless you explicitly lift state (e.g. via provide/inject or a store).
OOP style
export class Counter {
private _value: number
private _history: number[] = []
constructor(initial = 0) {
this._value = initial
this._history = [initial]
}
get value() {
return this._value
}
get history(): readonly number[] {
return this._history
}
increment() {
this._value++
this._history = [...this._history, this._value]
}
decrement() {
this._value--
this._history = [...this._history, this._value]
}
reset(initial = 0) {
this._value = initial
this._history = [initial]
}
get canUndo(): boolean {
return this._history.length > 1
}
undo() {
if (!this.canUndo) return
this._history = this._history.slice(0, -1)
this._value = this._history[this._history.length - 1]
}
}
Usage in a component (wiring to Vue reactivity):
import { ref, reactive } from 'vue'
const counter = ref(new Counter(10))
// Expose for template; mutations go through counter.value methods
counter.value.increment()
Here the "single source of truth" is the Counter instance. Vue doesn't track class internals by default, so you typically hold the instance in a ref and trigger updates by calling methods (or by wrapping selected state in reactive/ref if you need fine-grained reactivity).
Pros and cons
Composables — pros
- Vue-native: Use
ref,reactive,computed,watchdirectly. The template and the rest of the Vue ecosystem understand them; no "bridge" layer. - Tree-shakeable and explicit: You import only the functions you need. No hidden instance state; dependencies are function arguments and returned refs.
- Easy to test: Pass in mocks as arguments, call the function, assert on returned refs and methods. No need to mock classes or constructors.
- Composable in the literal sense: Combine small composables into larger ones (
useCounter+useLocalStorage+useDebounce). Composition over inheritance. - One call per component: Each
useCounter()gives independent state. No accidental shared mutable state unless you design for it (e.g. shared ref provided from a parent). - Works great with TypeScript: Return type is a single object; inference and autocomplete are straightforward.
Composables — cons
- Not a single "object" identity: You get a bag of refs and functions. If you need to pass "one thing" (e.g. to a store or a hierarchy of services), you pass the object returned from the composable; that's less natural than passing a class instance.
- Reactivity is your only "model": Complex domain rules or multi-step workflows often end up as more procedural code inside composables; OOP can give a clearer boundary (encapsulation, invariants).
- Shared state requires discipline: To share state across components you must lift it (e.g.
provide/inject, Pinia). With OOP you can pass the same instance around. - No inheritance: If you like "extend a base and override methods," composables don't offer that; you compose instead.
OOP — pros
- Single identity and encapsulation: One instance, one place for state and behavior. Good for domain models, services, adapters. Easy to pass "the counter" or "the auth service" down the tree.
- Familiar to many developers: Classes,
this, methods, inheritance—matches common backend and desktop patterns. Fits dependency-injection and "service" style architectures. - Invariants and rules in one place: You can enforce rules inside the class (e.g. "history never empty," "value never negative"). Callers use a clear API.
- Reuse outside Vue: The same class can be used in Node, workers, or non-Vue UI. No dependency on
reforreactive. - Shared state by reference: Pass one instance to many components; they all see the same state. No need for a separate "store" abstraction if you don't want one.
OOP — cons
- Vue doesn't track class internals: Mutating
this._valuedoesn't trigger updates unless you expose it via aref/reactiveor call a method that updates something Vue tracks. You often end up with a "bridge" (e.g. a ref holding the instance and template callingcounter.increment()). - Testing can need more setup: You may need to instantiate dependencies or use DI to inject mocks. Still testable, but sometimes more boilerplate than "call a function with plain args."
- Less natural with Composition API: The "composition" mindset is functional; mixing classes and composables in the same file can feel uneven. Possible, but two mental models.
- Inheritance can get messy: Deep hierarchies and overrides can make behavior hard to follow; composition (composables or "composition" of small classes) often scales better.
When to use which
- Prefer composables when:
- You're in a Vue app and the logic is UI-centric: form state, local UI state, watchers, derived values for the template. Composables are the idiomatic way.
- You want per-component state (each component gets its own copy) and don't need a shared "single instance" identity.
- You care about tree-shaking and small bundles; composables are just functions.
- You like composition over inheritance and combining small pieces (
useFetch,useDebounce,useLocalStorage). - You want minimal friction with Vue's reactivity and the rest of the ecosystem (Pinia, VueUse, etc.).
- Prefer OOP when:
- You're modeling domain entities or services (e.g.
AuthService,PaymentProcessor,Cart) and want a clear boundary and a single "thing" to pass around or inject. - You need shared mutable state by reference (one instance used in many places) and don't want to route everything through a store.
- You have complex invariants or multi-step workflows that benefit from encapsulation and methods that guard state.
- You want the same logic to run outside Vue (Node, worker, CLI) with zero Vue dependency.
- Your team is more comfortable with classes and DI and you're building a service layer or adapter layer.
- You're modeling domain entities or services (e.g.
You can mix: use composables for UI and local state, and OOP for domain or shared services. For example, a composable useAuth() might wrap an injected AuthService class and expose refs and methods for the template.
A slightly larger example: auth state
Composable: state lives in refs; methods call an API and update those refs.
// useAuth.ts
import { ref, computed } from 'vue'
export function useAuth() {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const isAuthenticated = computed(() => user.value !== null)
async function login(credentials: Credentials) {
loading.value = true
error.value = null
try {
user.value = await api.login(credentials)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
function logout() {
user.value = null
}
return { user, loading, error, isAuthenticated, login, logout }
}
OOP: state and behavior live in a class; the composable (or component) can hold a ref to the instance and expose what Vue needs.
// AuthService.ts
export class AuthService {
private _user: User | null = null
private _listeners = new Set<(user: User | null) => void>()
get user(): User | null {
return this._user
}
subscribe(listener: (user: User | null) => void) {
this._listeners.add(listener)
return () => this._listeners.delete(listener)
}
private notify() {
this._listeners.forEach((fn) => fn(this._user))
}
async login(credentials: Credentials) {
this._user = await api.login(credentials)
this.notify()
}
logout() {
this._user = null
this.notify()
}
}
// useAuth.ts — bridge to Vue
export function useAuth(authService: AuthService) {
const user = ref<User | null>(authService.user)
authService.subscribe((u) => { user.value = u })
return {
user,
login: (c: Credentials) => authService.login(c),
logout: () => authService.logout(),
}
}
Here the service is OOP (one instance, testable without Vue, reusable in Node); the Vue layer is a thin composable that subscribes and exposes refs and methods. Best of both.
Summary
- Composables: functions that return reactive state and methods. Vue-native, great for UI state, per-component state, and composition. Prefer when the logic is tied to the view and you want minimal boilerplate and maximum ecosystem fit.
- OOP: classes with instance state and methods. Good for domain models, services, shared state by reference, and logic that must run outside Vue. Prefer when you need a clear "object" identity, encapsulation, or reuse beyond the UI layer.
- When: use composables for UI and local state; use OOP for services and domain logic when it pays off. You can combine them by having composables wrap or use class instances and expose refs and methods for the template.