ubugeeei/style-guide.vue
One of Vue.js's greatest strengths is that it lets you write code the way you want to. There is no single correct style — and that flexibility is a feature, not a bug.
This guide is purely @ubugeeei's personal preference. It does not represent the consensus of the Vue.js core team or the official recommendation of the project.
Take what resonates, ignore what doesn't.
General
- Use SFC (.vue) + TypeScript
- Use
<script setup lang="ts">as default - Extract things unsuitable for setup (e.g. utility functions) into
<script lang="ts">or a separate.tsfile - Do not use auto imports. Always write explicit imports
- Use
import typeas a separate declaration, not inlineimport { type T }. SetverbatimModuleSyntax: trueintsconfig.json - Set
noUncheckedIndexedAccess: trueintsconfig.jsonto make index access returnT | undefined - Do not use implicit globals in
<template>($router,$t, etc.). Bind them explicitly from<script setup> - Always explicitly import components in
<script setup>. Do not rely on global registration - Do not use custom directives or custom blocks
- Do not use JavaScript classes. Prefer plain objects and functions
- Prefer controlled components. Avoid uncontrolled components that manage their own state internally
- When handling URLs, always read
baseUrlfrom configuration. Never hardcode it - Use pnpm, oxlint, oxfmt, tsdown, and Vitest as the standard toolchain
- Minimize third-party dependencies. Apart from Pinia, Pinia Colada, and Vue Router, implement it yourself whenever possible
- Do not introduce layered architecture until truly necessary. Use msw or similar for API mocking instead of abstracting layers for testability
- Consolidate navigation guards into a single
router.tsfile for a bird's-eye view of routing behavior - Always validate values from external boundaries (route params, localStorage, API responses, etc.) — especially user-controllable inputs. Never trust them as-is
SFC Block Order
<!-- only when needed -->
<script lang="ts"></script>
<script setup lang="ts"></script>
<template></template>
<style scoped></style>
<!-- only when global styles are needed -->
<style></style>
Component Layers
Think in two layers. Atomic Design is overkill.
- Primitive — Generic UI building blocks (
Button,Card,Dialog, etc.). No business logic - Feature — Components that compose primitives and contain business logic
Colocation
Colocate implementation. Do not create classification directories like components/ or composables/.
Place related files in the same directory.
features/
todo/
TodoPage.vue
TodoItem.vue
useTodo.ts
todo.ts # Pure TS logic
todo.test.ts
State Design
- Use ADTs (Algebraic Data Types) to eliminate impossible state combinations at the type level
- Only define essential state. Derive everything else with
computed - Do not use implicit reactivity (
reactive()). Useref()andRef<T> - Use
letfor mutable variables that do not need reactivity. Not everything needs to be aref(e.g.let timerId: ReturnType<typeof setTimeout>) - Use nominal typing with
unique symbolfor IDs, timestamps, dimensions, etc. Avoid barestringornumberto prevent mixing up units or identifiers - Do not use
defineModel
declare const UserIdMarker: unique symbol;
type UserId = string & { readonly [UserIdMarker]: never };
declare const PxMarker: unique symbol;
type Px = number & { readonly [PxMarker]: never };
// Bad: impossible combinations can exist
const isLoading = ref(false);
const error = ref<Error | null>(null);
const data = ref<Data | null>(null);
// Good: represent state transitions with ADT
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "ok"; data: Data };
const state = ref<State>({ status: "idle" });
// Good: derive with computed
const isLoading = computed(() => state.value.status === "loading");
Reactivity Layer vs Pure TypeScript
Think in terms of MVVM. Separate logic into two layers:
- Pure TypeScript (Model) — Framework-independent pure functions and type definitions. Easy to test
- Reactivity Binding (ViewModel) — Connects the Model to the View (template) via
ref,computed,watch
Push as much logic as possible into the Model layer. The ViewModel should be a thin binding layer, not the home of business logic.
In the Pure TS layer, use arrow functions for a functional style. Use function declarations in setup and composables.
Separate type declarations and function signatures from implementation. When beneficial, extract signatures into a .def.ts file — this is especially useful for Agentic Coding, where the agent can read the definition file to understand the contract without scanning the full implementation.
// todo.ts — Pure TS
// --- Types & Signatures ---
export type Todo = { id: string; title: string; done: boolean };
export type toggleTodo = (todo: Todo) => Todo;
export type remaining = (todos: Todo[]) => number;
// --- Implementation ---
export const toggleTodo: toggleTodo = (todo) => ({
...todo,
done: !todo.done,
});
export const remaining: remaining = (todos) => todos.filter((t) => !t.done).length;
// useTodo.ts — Reactivity Binding
import type { Ref } from "vue";
import { ref, computed } from "vue";
import type { Todo } from "./todo";
import { toggleTodo, remaining } from "./todo";
export function useTodo(initial: Todo[]) {
const todos: Ref<Todo[]> = ref(initial);
const remainingCount = computed(() => remaining(todos.value));
function toggle(id: string) {
todos.value = todos.value.map((t) => (t.id === id ? toggleTodo(t) : t));
}
return { todos, remainingCount, toggle };
}
Composable
- Use
functiondeclarations for top-level functions in setup and composables (not arrow functions) - Before writing a composable, first define the required states and their transitions at the type level
- Be mindful of Vue lifecycle hooks (
onMounted,onUnmountedfor cleanup, etc.) - Must be Server-Client Universal. Do not access client globals like
windowat the top level - Avoid
useRouter,inject, or other implicit dependencies inside composables. They force hidden contracts on callers. Instead, accept the essential values and callbacks as arguments. Exception:provide/injectinside a composable is acceptable when the usage pattern is well-established, the caller should not need to be aware of it, and it simplifies the API
// Bad: hidden dependency on router
function useTodoNav() {
const router = useRouter();
const id = computed(() => router.currentRoute.value.params.id as string);
function goToDetail(id: string) {
router.push(`/todo/${id}`);
}
return { id, goToDetail };
}
// Good: explicit inputs, no implicit context
function useTodoNav(currentId: Readonly<Ref<string>>, onSelectTodo: (id: string) => void) {
// ...
}
- Do not blindly destructure when calling
use*. Preserve cohesion
// Bad: cohesion is lost
const { data, error, isLoading, refetch, abort } = useFetch("/api/todos");
// Good: concerns stay grouped
const todosQuery = useFetch("/api/todos");
todosQuery.data; // reference is also clear
Trade-off: Keeping the object intact means
.valueis required in<template>bindings (e.g.todosQuery.data.value). This approach pays off when the composable surface is large; for simple cases with few return values, destructuring may be more pragmatic.
Readonly & Mutation Boundary
- Use
readonly()to control the public surface of state - When passing stateful values across functions, default to
readonly - Functions that intentionally mutate must have a
Mutsuffix with documented rationale
import type { Ref } from "vue";
import { readonly } from "vue";
function useCounter() {
const count = ref(0);
/** Mut: callers need to reset after form submission */
function resetMut(c: Ref<number>) {
c.value = 0;
}
return { count: readonly(count), resetMut };
}
Side Effects
- Do not use
watch/watchEffectuntil truly necessary - If you need
nextTick, revisit your design first - All async operations must handle race conditions. Use
AbortControllerto cancel stale requests and guard against stale closures - Design error handling deliberately and document it.
throw,onErrorCaptured, and error boundaries are hard to reason about — make error flows explicit and traceable - Avoid direct DOM access via template refs or
document.*. Raw DOM manipulation introduces scheduler coupling and makesnextTickissues harder to reason about - When using template refs, always define
defineExposeto make the public interface explicit
Props, Emits & Slots
Always use type-only declarations for defineProps, defineEmits, and defineSlots. Always use Props Destructure. For defineEmits, use the tuple syntax (not the call signature form).
<script setup lang="ts">
const { title, count = 0 } = defineProps<{
title: string;
count?: number;
}>();
const emit = defineEmits<{
"click:avatar": [];
"update:nickname": [value: string];
}>();
defineSlots<{
default: (props: { item: Item }) => unknown;
header: () => unknown;
}>();
</script>
Events
Scope event handlers with : as a namespace separator.
- For native-like interactions (
click,update, etc.), use the event name as the prefix — groups interaction types for easy scanning - For domain-specific notifications, use the domain as the prefix — makes the subject clear
<template>
<UserCard
@click:avatar="onClickAvatar"
@click:follow-button="onClickFollowButton"
@update:nickname="onUpdateNickname"
/>
<AudioPlayer @audio:play="onAudioPlay" @audio:pause="onAudioPause" @audio:end="onAudioEnd" />
</template>
Provide / Inject & Global Store
Keep to an absolute minimum. If props and emit suffice, use those.
When using Provide/Inject, always use InjectionKey<T> with Symbol for type safety.
import type { InjectionKey } from "vue";
export const ThemeKey: InjectionKey<Theme> = Symbol("Theme");
When using global state, always document the state lifecycle: transitions and scope of usage.
Template Optimization
- Do not use
v-memoorv-once. These are likely to become obsolete with future optimizations like Vapor - Do not use
KeepAliveuntil truly necessary. Cache invalidation adds significant complexity - Leverage
:keyto reset component state instead of manual cleanup logic - Do not use
shallowRefor async components (defineAsyncComponent) until truly necessary. Premature optimization adds complexity without proven benefit
CSS
- Do not use CSS pre-processors (SCSS, Sass, etc.)
- Do not use utility classes (Tailwind, etc.)
- Do not use
v-bindin<style> - Avoid
:stylebindings. Prefer CSS variables and class switching - Avoid magic values. Use CSS custom properties (
var(--*)) instead of hardcoded literals - Prefer semantic selectors (
article,nav,h2,[aria-expanded], etc.) over class-heavy markup - Use CSS Nesting (use Lightning CSS for downcompilation if needed)
<style scoped>
.card {
border: 1px solid var(--color-border);
& .title {
font-size: 1.25rem;
}
&:hover {
border-color: var(--color-primary);
}
}
</style>
Testing
- Write unit / integration tests in Vitest for TypeScript logic
- Do not write component tests
- Use E2E tests and VRT (Visual Regression Testing) instead
- Testing is easy because logic lives in the Pure TS layer
// todo.test.ts
import { describe, it, expect } from "vitest";
import { toggleTodo, remaining } from "./todo";
describe("toggleTodo", () => {
it("toggles done", () => {
const todo = { id: "1", title: "test", done: false };
expect(toggleTodo(todo).done).toBe(true);
});
});