--- title: Avoid Hidden Side Effects in Composables impact: HIGH impactDescription: Side effects hidden in composables make debugging difficult and create implicit coupling between components type: best-practice tags: [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:** ```javascript // 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:** ```javascript // 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: ```javascript /** * 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 ```javascript // 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 - [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html) - [Common Mistakes Creating Composition Functions](https://www.telerik.com/blogs/common-mistakes-creating-composition-functions-vue)