Skip to main content
Vertz has strong conventions. None of these are enforced by the compiler or runtime — they’re patterns that keep your code consistent, readable, and predictable for both humans and LLMs.

Component signatures

Destructure props in the parameter

// Preferred
export function TaskCard({ task, onClick }: TaskCardProps) {
  return <div onClick={() => onClick(task.id)}>{task.title}</div>;
}

// Avoid
export function TaskCard(props: TaskCardProps) {
  const { task, onClick } = props;
  return <div onClick={() => onClick(task.id)}>{task.title}</div>;
}

Props interface naming

Name the interface ComponentNameProps. Use the on prefix for callback props.
interface TaskCardProps {
  task: Task;
  onClick: (id: string) => void;
  onDelete?: (id: string) => void;
}

Don’t annotate return types

Let TypeScript infer component return types. The JSX factory maps tag names to specific element types — an explicit : HTMLElement annotation is a lossy upcast.
// Preferred — TypeScript infers HTMLFormElement
export function TaskForm({ onSuccess }: TaskFormProps) {
  return <form>...</form>;
}

// Avoid — loses type specificity
export function TaskForm({ onSuccess }: TaskFormProps): HTMLElement {
  return <form>...</form>;
}

Reactivity

let for local state

Use let for local state. The compiler transforms it into a signal automatically.
let count = 0;
let isOpen = false;

return (
  <button
    onClick={() => {
      count++;
      isOpen = true;
    }}
  >
    {count}
  </button>
);

const for derived values

Use const for values derived from state. The compiler wraps it in computed() automatically.
let count = 0;
const doubled = count * 2;
const label = count === 0 ? 'empty' : `${count} items`;

JSX

Fully declarative

All UI code must be declarative. No appendChild, innerHTML, textContent assignment, setAttribute, or document.createElement.
// Preferred
return <div className={styles.panel}>{title}</div>;

// Avoid
const el = document.createElement('div');
el.textContent = title;
el.className = styles.panel;
If you can’t express something declaratively, that’s a framework gap — not a reason to use imperative DOM APIs.

Use JSX for components, not function calls

// Preferred
<TaskCard task={task} onClick={handleClick} />;

// Avoid
{
  TaskCard({ task, onClick: handleClick });
}

Conditionals with && and ternary

{
  isLoading && <div>Loading...</div>;
}
{
  error ? <ErrorView error={error} /> : <Content data={data} />;
}

Lists with .map() and key

{
  tasks.map((task) => <TaskCard key={task.id} task={task} />);
}

Styling

css() for scoped styles

const styles = css({
  card: ['bg:card', 'rounded:lg', 'p:4'],
  title: ['font:lg', 'font:bold'],
});

variants() for parameterized styles

const button = variants({
  base: ['inline-flex', 'rounded:md', 'font:medium'],
  variants: {
    intent: {
      primary: ['bg:primary', 'text:white'],
      ghost: ['bg:transparent', 'text:foreground'],
    },
    size: {
      sm: ['text:xs', 'px:3'],
      md: ['text:sm', 'px:4'],
    },
  },
  defaultVariants: { intent: 'primary', size: 'md' },
});

Inline style only for dynamic values

Use css() tokens for anything theme-related. Reserve inline styles for one-off layout values like margins, transforms, or dynamic positioning.
// Theme values — use css() tokens
const styles = css({
  panel: ['bg:card', 'rounded:lg', 'p:6'],
});

// Dynamic one-off layout — inline style is fine
<div className={styles.panel} style={`margin-top: ${offset}px`}>
  {content}
</div>;

Data fetching

query() is auto-disposed

Don’t manually manage query lifecycle — queries auto-register cleanup with the component scope. When the component unmounts, the query stops reactive effects and timers automatically.
const tasks = query(api.tasks.list());

// Just use it — no cleanup needed
return (
  <ul>
    {tasks.data?.items.map((task) => (
      <li key={task.id}>{task.title}</li>
    ))}
  </ul>
);

Access query properties directly

Query properties like .data, .loading, and .error work directly in both JSX and event handlers — no unwrapping needed.
const tasks = query(api.tasks.list());

return (
  <div>
    {tasks.loading && <span>Loading...</span>}
    {tasks.error && <span>Failed to load</span>}
    {tasks.data?.items.map((task) => (
      <li key={task.id}>{task.title}</li>
    ))}
    <button onClick={() => console.log(tasks.data?.items.length)}>Log count</button>
  </div>
);

Context

Create a convenience hook

Always create a typed use* accessor that throws on missing provider.
export const SettingsContext = createContext<SettingsContextValue>();

export function useSettings(): SettingsContextValue {
  const ctx = useContext(SettingsContext);
  if (!ctx) throw new Error('useSettings must be called within SettingsContext.Provider');
  return ctx;
}

Router

Pages use useRouter(), not props

Pages access the router via context — no prop threading.
// Preferred — page gets router from context
export function TaskListPage() {
  const { navigate } = useRouter();
  // ...
}

// Avoid — threading navigate through route definitions
'/': { component: () => TaskListPage({ navigate: router.navigate }) }