Files
agent-skills/skills/vue-best-practices/reference/composable-avoid-hidden-side-effects.md
Jason Woltje f5792c40be feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories:
- anthropics/skills: 16 (docs, design, MCP, testing)
- obra/superpowers: 14 (TDD, debugging, agents, planning)
- coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth)
- better-auth/skills: 5 (auth patterns)
- vercel-labs/agent-skills: 5 (React, design, Vercel)
- antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo)
- Plus 13 individual skills from various repos

Mosaic Stack is not limited to coding — the Orchestrator and
subagents serve coding, business, design, marketing, writing,
logistics, analysis, and more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:27:42 -06:00

5.7 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Avoid Hidden Side Effects in Composables HIGH Side effects hidden in composables make debugging difficult and create implicit coupling between components best-practice
vue3
composables
composition-api
side-effects
provide-inject
global-state

Avoid Hidden Side Effects in Composables

Impact: HIGH - Composables should encapsulate stateful logic, not hide side effects that affect things outside their scope. Hidden side effects like modifying global state, using provide/inject internally, or manipulating the DOM directly make composables unpredictable and hard to debug.

When a composable has unexpected side effects, consumers can't reason about what calling it will do. This leads to bugs that are difficult to trace and composables that can't be safely reused.

Task Checklist

  • Avoid using provide/inject inside composables (make dependencies explicit)
  • Don't modify Pinia/Vuex store state internally (accept store as parameter instead)
  • Don't manipulate DOM directly (use template refs passed as arguments)
  • Document any unavoidable side effects clearly
  • Keep composables focused on returning reactive state and methods

Incorrect:

// WRONG: Hidden provide/inject dependency
export function useTheme() {
  // Consumer has no idea this depends on a provided theme
  const theme = inject('theme')  // What if nothing provides this?

  const isDark = computed(() => theme?.mode === 'dark')
  return { isDark }
}

// WRONG: Modifying global store internally
import { useUserStore } from '@/stores/user'

export function useLogin() {
  const userStore = useUserStore()

  async function login(credentials) {
    const user = await api.login(credentials)
    // Hidden side effect: modifying global state
    userStore.setUser(user)
    userStore.setToken(user.token)
    // Consumer doesn't know the store was modified!
  }

  return { login }
}

// WRONG: Hidden DOM manipulation
export function useFocusTrap() {
  onMounted(() => {
    // Which element? Consumer has no control
    document.querySelector('.modal')?.focus()
  })
}

// WRONG: Hidden provide that affects descendants
export function useFormContext() {
  const form = reactive({ values: {}, errors: {} })
  // Components calling this have no idea it provides something
  provide('form-context', form)
  return form
}

Correct:

// CORRECT: Explicit dependency injection
export function useTheme(injectedTheme) {
  // If no theme passed, consumer must handle it
  const theme = injectedTheme ?? { mode: 'light' }

  const isDark = computed(() => theme.mode === 'dark')
  return { isDark }
}

// Usage - dependency is explicit
const theme = inject('theme', { mode: 'light' })
const { isDark } = useTheme(theme)

// CORRECT: Return actions, let consumer decide when to call them
export function useLogin() {
  const user = ref(null)
  const token = ref(null)
  const isLoading = ref(false)
  const error = ref(null)

  async function login(credentials) {
    isLoading.value = true
    error.value = null
    try {
      const response = await api.login(credentials)
      user.value = response.user
      token.value = response.token
      return response
    } catch (e) {
      error.value = e
      throw e
    } finally {
      isLoading.value = false
    }
  }

  return { user, token, isLoading, error, login }
}

// Consumer decides what to do with the result
const { user, token, login } = useLogin()
const userStore = useUserStore()

async function handleLogin(credentials) {
  await login(credentials)
  // Consumer explicitly updates the store
  userStore.setUser(user.value)
  userStore.setToken(token.value)
}

// CORRECT: Accept element as parameter
export function useFocusTrap(targetRef) {
  onMounted(() => {
    targetRef.value?.focus()
  })

  onUnmounted(() => {
    // Cleanup focus trap
  })
}

// Usage - consumer controls which element
const modalRef = ref(null)
useFocusTrap(modalRef)

// CORRECT: Separate composable from provider
export function useFormContext() {
  const form = reactive({ values: {}, errors: {} })
  return form
}

// In parent component - explicit provide
const form = useFormContext()
provide('form-context', form)

Acceptable Side Effects (With Documentation)

Some side effects are acceptable when they're the core purpose of the composable:

/**
 * Tracks mouse position globally.
 *
 * SIDE EFFECTS:
 * - Adds 'mousemove' event listener to window (cleaned up on unmount)
 *
 * @returns {Object} Mouse coordinates { x, y }
 */
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // This side effect is the whole point of the composable
  // and is properly cleaned up
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  return { x, y }
}

Pattern: Dependency Injection for Flexibility

// Composable accepts its dependencies
export function useDataFetcher(apiClient, cache = null) {
  const data = ref(null)

  async function fetch(url) {
    if (cache) {
      const cached = cache.get(url)
      if (cached) {
        data.value = cached
        return
      }
    }

    data.value = await apiClient.get(url)
    cache?.set(url, data.value)
  }

  return { data, fetch }
}

// Usage - dependencies are explicit and testable
const apiClient = inject('apiClient')
const cache = inject('cache', null)
const { data, fetch } = useDataFetcher(apiClient, cache)

Reference