Create composable function with reactive state defined outside setup(). Pattern: export const useState = () => { const state = reactive({ user: null, isAuthenticated: false }); const login = (user) => { state.user = user; state.isAuthenticated = true; }; return { state: readonly(state), login }; }. Use readonly() to prevent external mutations. Import in components: const { state, login } = useState();. Singleton pattern: define state outside function for shared instance across all components. Best practices: (1) Use Pinia for complex apps (official recommendation 2025), (2) Composables for simple global state (<5 properties), (3) Separate global from local state, (4) Create modular stores per feature (useAuth, useCart, useTheme), (5) Use provide/inject for scoped state (not app-wide). Avoid one giant global context, prefer multiple specialized contexts. TypeScript: interface State { user: User | null; }. For non-singleton instances use composables, for singleton use Pinia.
Vue.js State Composables FAQ & Answers
28 expert Vue.js State Composables answers researched from official documentation. Every answer cites authoritative sources you can verify.
unknown
28 questionsUse CSS custom properties with reactive theme state. Pattern: create useTheme composable: const theme = ref<'light' | 'dark'>('light'); const setTheme = (newTheme) => { theme.value = newTheme; document.documentElement.setAttribute('data-theme', newTheme); }; return { theme, setTheme };. CSS: [data-theme='light'] { --bg-color: #fff; --text-color: #000; } [data-theme='dark'] { --bg-color: #1a1a1a; --text-color: #fff; }. Components use CSS variables: .button { background: var(--bg-color); color: var(--text-color); }. Persist theme: watchEffect(() => { localStorage.setItem('theme', theme.value); }); onMounted(() => { const saved = localStorage.getItem('theme'); if (saved) setTheme(saved); });. Detect system preference: const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches. Tailwind alternative: use dark: prefix classes with dark mode config. TypeScript types: type Theme = 'light' | 'dark' | 'auto'. Best practice: provide theme at root, inject in components needing theme-specific logic.
Composables solve critical mixin problems. Benefits: (1) Clear source: import { useCounter } from './composables' shows exact origin vs mixins where property source is unclear, (2) No namespace collisions: composables use explicit imports, rename on conflict (import { count as userCount }), mixins merge properties causing conflicts, (3) Better TypeScript: composables provide natural type inference (standard functions/variables), mixins have poor type support, (4) Modular reusability: compose multiple composables cleanly, mixins create brittle inheritance chains, (5) Explicit dependencies: see all used composables in import statements, mixins hide dependencies, (6) Flexible naming: destructure and rename returned values, mixins force specific property names. Example: const { count, increment } = useCounter(); const { count: todoCount } = useTodos(); // Rename to avoid collision. 2025 recommendation: Composition API with composables is the standard for modern, scalable Vue apps. Mixins remain only for legacy Options API code. Migration path: convert mixins to composables when refactoring.
DRY (Don't Repeat Yourself) principle: every piece of knowledge should have single, unambiguous representation in codebase. In Vue: (1) Extract reusable components:
SOLID principles adapted for Vue: (S) Single Responsibility: component does one thing, e.g.,
Follow these patterns for reusable composables: (1) Start with 'use' prefix: useAuth, useCart, usePagination (convention), (2) Return object with clear API: return { data, loading, error, refresh } not loose variables, (3) Accept configuration options: useApi({ baseURL, timeout, retries }), (4) Use TypeScript generics: useFetch
Use local reactive state within modal with props for initial values. Pattern: const formData = reactive({ ...props.initialData }); avoids mutating props directly. Reset on close: const resetForm = () => { Object.assign(formData, props.initialData); }; watch(isOpen, (open) => { if (!open) resetForm(); });. Validation: use VeeValidate or custom composable: const { errors, validate } = useValidation(formData);. Submit: const handleSubmit = async () => { if (await validate()) { emit('save', toRaw(formData)); close(); } };. Prevent accidental close: const confirmClose = () => { if (hasUnsavedChanges.value) { openDialog(ConfirmDialog, { message: 'Discard changes?' }).then(close); } else close(); };. Best practices: (1) Clone initial data (avoid reference mutations), (2) Use toRaw() before emitting (removes reactivity proxies), (3) Disable submit during loading, (4) Focus first input on mount, (5) Handle Enter/Escape keys. TypeScript: const formData = reactive
Update UI immediately, rollback on error. Pattern with TanStack Query (Vue Query): const { mutate } = useMutation({ mutationFn: updateTodo, onMutate: async (newTodo) => { await queryClient.cancelQueries(['todos']); const previous = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], old => [...old, newTodo]); return { previous }; }, onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previous); }, onSuccess: () => { queryClient.invalidateQueries(['todos']); } }); mutate(newTodo);. Manual implementation: const optimisticUpdate = async (item) => { items.value.push(item); // Optimistic UI try { const saved = await api.save(item); items.value = items.value.map(i => i.id === item.id ? saved : i); } catch (error) { items.value = items.value.filter(i => i.id !== item.id); // Rollback showError('Save failed'); } };. Benefits: instant feedback, better perceived performance. Risks: complexity, possible rollback jarring UX. Best for: high-success-rate operations (likes, toggles), not critical data (payments). Use loading indicators during server sync.
Use VueUse's useDebounceFn or useDebounce for reactive debouncing. Function debounce: import { useDebounceFn } from '@vueuse/core'; const debouncedSearch = useDebounceFn(async (query) => { results.value = await api.search(query); }, 500); // 500ms delay. Usage: <input @input="debouncedSearch($event.target.value)" />. Value debounce: const searchQuery = ref(''); const debouncedQuery = useDebounce(searchQuery, 500); watch(debouncedQuery, async (value) => { results.value = await api.search(value); });. Options: useDebounceFn(fn, 500, { maxWait: 2000 }). Manual implementation: let timeoutId; const debounce = (fn, delay) => (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; const debouncedFn = debounce(search, 500);. Cleanup: onUnmounted(() => clearTimeout(timeoutId));. Use cases: search input (500ms), window resize (200ms), autosave (1000ms). Combine with loading state: const isSearching = ref(false). VueUse watchDebounced: automatic watch + debounce.
Use reactive boolean flags with consistent patterns. Simple pattern: const isLoading = ref(false); const fetchData = async () => { isLoading.value = true; try { data.value = await api.get(); } finally { isLoading.value = false; } };. Template:
Use centralized error handling with user-friendly messages. Pattern: create error interceptor in API client: axios.interceptors.response.use(response => response, error => { if (error.response?.status === 401) { router.push('/login'); } else if (error.response?.status === 403) { showError('Permission denied'); } else if (error.response?.status >= 500) { showError('Server error. Please try again.'); } else { showError(error.response?.data?.message || 'An error occurred'); } return Promise.reject(error); });. Component level: const { data, error, isLoading } = useAsync(api.getUser); if (error.value) { showError(error.value.message); }. Global error handler: app.config.errorHandler = (err) => { console.error(err); Sentry.captureException(err); };. User-facing errors: use toast notifications (vue-toastification), not console.log. TypeScript: class ApiError extends Error { constructor(public status: number, message: string) { super(message); } }. Best practices: (1) Log errors to monitoring service (Sentry, LogRocket), (2) Show actionable messages ('Retry' button), (3) Handle offline state, (4) Use error boundaries for component errors.
Use exponential backoff with retry limit. Pattern with axios-retry: npm install axios-retry; import axiosRetry from 'axios-retry'; axiosRetry(axios, { retries: 3, retryDelay: (retryCount) => retryCount * 1000, retryCondition: (error) => axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response?.status >= 500 }); // Retries: 1s, 2s, 3s. Manual implementation: const fetchWithRetry = async (fn, maxRetries = 3) => { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (i === maxRetries - 1) throw error; const delay = Math.min(1000 * 2 ** i, 10000); // Exponential: 1s, 2s, 4s, max 10s await new Promise(resolve => setTimeout(resolve, delay)); } } };. Usage: const data = await fetchWithRetry(() => api.getUser()); TanStack Query built-in: useQuery({ queryKey: ['user'], queryFn: getUser, retry: 3, retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000) });. Best practices: (1) Only retry idempotent requests (GET, not POST), (2) Retry network errors and 5xx, not 4xx, (3) Show retry attempt to user, (4) Add jitter to prevent thundering herd.
Use computed() function for cached reactive derivations. Basic: import { computed } from 'vue'; const count = ref(0); const double = computed(() => count.value * 2); // Auto-updates when count changes. Writable computed: const firstName = ref('John'); const lastName = ref('Doe'); const fullName = computed({ get: () => ${firstName.value} ${lastName.value}, set: (value) => { [firstName.value, lastName.value] = value.split(' '); } }); fullName.value = 'Jane Smith'; // Updates firstName and lastName. Benefits vs methods: (1) Cached: only recalculates when dependencies change, methods run on every render, (2) Reactive: automatically tracks dependencies, (3) Lazy: doesn't evaluate until accessed. Multiple dependencies: const summary = computed(() => ${user.value.name} has ${items.value.length} items);. TypeScript: const total = computed
Use ref() for primitives, reactive() for objects in Composition API. ref: const count = ref(0); count.value++; // Access with .value. Auto-unwrapped in templates: {{ count }}. reactive: const state = reactive({ count: 0, user: null }); state.count++; // No .value needed. Choose ref for: primitives (string, number, boolean), need reassignment (user.value = newUser). Choose reactive for: objects/arrays, prefer dot notation (state.user.name). Deep reactivity: nested properties automatically reactive. Shallow: shallowRef/shallowReactive for performance. toRefs: convert reactive object to refs: const { count, user } = toRefs(state); // count.value, user.value. readonly: prevent mutations: const readonlyState = readonly(state);. Best practices: (1) Pinia for global state (2025 official recommendation), (2) Local state with ref/reactive in components, (3) Consistent pattern: all ref or all reactive per component, (4) Use TypeScript interfaces for reactive objects: const state = reactive
Create composable function with reactive state defined outside setup(). Pattern: export const useState = () => { const state = reactive({ user: null, isAuthenticated: false }); const login = (user) => { state.user = user; state.isAuthenticated = true; }; return { state: readonly(state), login }; }. Use readonly() to prevent external mutations. Import in components: const { state, login } = useState();. Singleton pattern: define state outside function for shared instance across all components. Best practices: (1) Use Pinia for complex apps (official recommendation 2025), (2) Composables for simple global state (<5 properties), (3) Separate global from local state, (4) Create modular stores per feature (useAuth, useCart, useTheme), (5) Use provide/inject for scoped state (not app-wide). Avoid one giant global context, prefer multiple specialized contexts. TypeScript: interface State { user: User | null; }. For non-singleton instances use composables, for singleton use Pinia.
Use CSS custom properties with reactive theme state. Pattern: create useTheme composable: const theme = ref<'light' | 'dark'>('light'); const setTheme = (newTheme) => { theme.value = newTheme; document.documentElement.setAttribute('data-theme', newTheme); }; return { theme, setTheme };. CSS: [data-theme='light'] { --bg-color: #fff; --text-color: #000; } [data-theme='dark'] { --bg-color: #1a1a1a; --text-color: #fff; }. Components use CSS variables: .button { background: var(--bg-color); color: var(--text-color); }. Persist theme: watchEffect(() => { localStorage.setItem('theme', theme.value); }); onMounted(() => { const saved = localStorage.getItem('theme'); if (saved) setTheme(saved); });. Detect system preference: const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches. Tailwind alternative: use dark: prefix classes with dark mode config. TypeScript types: type Theme = 'light' | 'dark' | 'auto'. Best practice: provide theme at root, inject in components needing theme-specific logic.
Composables solve critical mixin problems. Benefits: (1) Clear source: import { useCounter } from './composables' shows exact origin vs mixins where property source is unclear, (2) No namespace collisions: composables use explicit imports, rename on conflict (import { count as userCount }), mixins merge properties causing conflicts, (3) Better TypeScript: composables provide natural type inference (standard functions/variables), mixins have poor type support, (4) Modular reusability: compose multiple composables cleanly, mixins create brittle inheritance chains, (5) Explicit dependencies: see all used composables in import statements, mixins hide dependencies, (6) Flexible naming: destructure and rename returned values, mixins force specific property names. Example: const { count, increment } = useCounter(); const { count: todoCount } = useTodos(); // Rename to avoid collision. 2025 recommendation: Composition API with composables is the standard for modern, scalable Vue apps. Mixins remain only for legacy Options API code. Migration path: convert mixins to composables when refactoring.
DRY (Don't Repeat Yourself) principle: every piece of knowledge should have single, unambiguous representation in codebase. In Vue: (1) Extract reusable components:
SOLID principles adapted for Vue: (S) Single Responsibility: component does one thing, e.g.,
Follow these patterns for reusable composables: (1) Start with 'use' prefix: useAuth, useCart, usePagination (convention), (2) Return object with clear API: return { data, loading, error, refresh } not loose variables, (3) Accept configuration options: useApi({ baseURL, timeout, retries }), (4) Use TypeScript generics: useFetch
Use local reactive state within modal with props for initial values. Pattern: const formData = reactive({ ...props.initialData }); avoids mutating props directly. Reset on close: const resetForm = () => { Object.assign(formData, props.initialData); }; watch(isOpen, (open) => { if (!open) resetForm(); });. Validation: use VeeValidate or custom composable: const { errors, validate } = useValidation(formData);. Submit: const handleSubmit = async () => { if (await validate()) { emit('save', toRaw(formData)); close(); } };. Prevent accidental close: const confirmClose = () => { if (hasUnsavedChanges.value) { openDialog(ConfirmDialog, { message: 'Discard changes?' }).then(close); } else close(); };. Best practices: (1) Clone initial data (avoid reference mutations), (2) Use toRaw() before emitting (removes reactivity proxies), (3) Disable submit during loading, (4) Focus first input on mount, (5) Handle Enter/Escape keys. TypeScript: const formData = reactive
Update UI immediately, rollback on error. Pattern with TanStack Query (Vue Query): const { mutate } = useMutation({ mutationFn: updateTodo, onMutate: async (newTodo) => { await queryClient.cancelQueries(['todos']); const previous = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], old => [...old, newTodo]); return { previous }; }, onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previous); }, onSuccess: () => { queryClient.invalidateQueries(['todos']); } }); mutate(newTodo);. Manual implementation: const optimisticUpdate = async (item) => { items.value.push(item); // Optimistic UI try { const saved = await api.save(item); items.value = items.value.map(i => i.id === item.id ? saved : i); } catch (error) { items.value = items.value.filter(i => i.id !== item.id); // Rollback showError('Save failed'); } };. Benefits: instant feedback, better perceived performance. Risks: complexity, possible rollback jarring UX. Best for: high-success-rate operations (likes, toggles), not critical data (payments). Use loading indicators during server sync.
Use VueUse's useDebounceFn or useDebounce for reactive debouncing. Function debounce: import { useDebounceFn } from '@vueuse/core'; const debouncedSearch = useDebounceFn(async (query) => { results.value = await api.search(query); }, 500); // 500ms delay. Usage: <input @input="debouncedSearch($event.target.value)" />. Value debounce: const searchQuery = ref(''); const debouncedQuery = useDebounce(searchQuery, 500); watch(debouncedQuery, async (value) => { results.value = await api.search(value); });. Options: useDebounceFn(fn, 500, { maxWait: 2000 }). Manual implementation: let timeoutId; const debounce = (fn, delay) => (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; const debouncedFn = debounce(search, 500);. Cleanup: onUnmounted(() => clearTimeout(timeoutId));. Use cases: search input (500ms), window resize (200ms), autosave (1000ms). Combine with loading state: const isSearching = ref(false). VueUse watchDebounced: automatic watch + debounce.
Use reactive boolean flags with consistent patterns. Simple pattern: const isLoading = ref(false); const fetchData = async () => { isLoading.value = true; try { data.value = await api.get(); } finally { isLoading.value = false; } };. Template:
Use centralized error handling with user-friendly messages. Pattern: create error interceptor in API client: axios.interceptors.response.use(response => response, error => { if (error.response?.status === 401) { router.push('/login'); } else if (error.response?.status === 403) { showError('Permission denied'); } else if (error.response?.status >= 500) { showError('Server error. Please try again.'); } else { showError(error.response?.data?.message || 'An error occurred'); } return Promise.reject(error); });. Component level: const { data, error, isLoading } = useAsync(api.getUser); if (error.value) { showError(error.value.message); }. Global error handler: app.config.errorHandler = (err) => { console.error(err); Sentry.captureException(err); };. User-facing errors: use toast notifications (vue-toastification), not console.log. TypeScript: class ApiError extends Error { constructor(public status: number, message: string) { super(message); } }. Best practices: (1) Log errors to monitoring service (Sentry, LogRocket), (2) Show actionable messages ('Retry' button), (3) Handle offline state, (4) Use error boundaries for component errors.
Use exponential backoff with retry limit. Pattern with axios-retry: npm install axios-retry; import axiosRetry from 'axios-retry'; axiosRetry(axios, { retries: 3, retryDelay: (retryCount) => retryCount * 1000, retryCondition: (error) => axiosRetry.isNetworkOrIdempotentRequestError(error) || error.response?.status >= 500 }); // Retries: 1s, 2s, 3s. Manual implementation: const fetchWithRetry = async (fn, maxRetries = 3) => { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { if (i === maxRetries - 1) throw error; const delay = Math.min(1000 * 2 ** i, 10000); // Exponential: 1s, 2s, 4s, max 10s await new Promise(resolve => setTimeout(resolve, delay)); } } };. Usage: const data = await fetchWithRetry(() => api.getUser()); TanStack Query built-in: useQuery({ queryKey: ['user'], queryFn: getUser, retry: 3, retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000) });. Best practices: (1) Only retry idempotent requests (GET, not POST), (2) Retry network errors and 5xx, not 4xx, (3) Show retry attempt to user, (4) Add jitter to prevent thundering herd.
Use computed() function for cached reactive derivations. Basic: import { computed } from 'vue'; const count = ref(0); const double = computed(() => count.value * 2); // Auto-updates when count changes. Writable computed: const firstName = ref('John'); const lastName = ref('Doe'); const fullName = computed({ get: () => ${firstName.value} ${lastName.value}, set: (value) => { [firstName.value, lastName.value] = value.split(' '); } }); fullName.value = 'Jane Smith'; // Updates firstName and lastName. Benefits vs methods: (1) Cached: only recalculates when dependencies change, methods run on every render, (2) Reactive: automatically tracks dependencies, (3) Lazy: doesn't evaluate until accessed. Multiple dependencies: const summary = computed(() => ${user.value.name} has ${items.value.length} items);. TypeScript: const total = computed
Use ref() for primitives, reactive() for objects in Composition API. ref: const count = ref(0); count.value++; // Access with .value. Auto-unwrapped in templates: {{ count }}. reactive: const state = reactive({ count: 0, user: null }); state.count++; // No .value needed. Choose ref for: primitives (string, number, boolean), need reassignment (user.value = newUser). Choose reactive for: objects/arrays, prefer dot notation (state.user.name). Deep reactivity: nested properties automatically reactive. Shallow: shallowRef/shallowReactive for performance. toRefs: convert reactive object to refs: const { count, user } = toRefs(state); // count.value, user.value. readonly: prevent mutations: const readonlyState = readonly(state);. Best practices: (1) Pinia for global state (2025 official recommendation), (2) Local state with ref/reactive in components, (3) Consistent pattern: all ref or all reactive per component, (4) Use TypeScript interfaces for reactive objects: const state = reactive