Components

Shared modules

Plain .ts modules hold the values, types and pure functions that components share, like design tokens and formatters. The compiler copies each one into every output tree unchanged, and components read their own module's top-level constants too.

Shared modules let a design system write its sizes, variant tables and small helpers once, and read them from every component. In Unframework they're plain .ts files that need no compiling: the compiler copies each one into every framework's output tree, byte for byte, beside the components that import it. Think of a size table pasted into ten components until one of them gets a new size, a price formatter that each framework's port rounds differently, or a tokens.ts that quietly imports React and breaks the Vue build.

This page walks you through what a component may read from a module, and what a shared module may hold. We'll look at:

  • Values from a .ts module: constants, tables and pure functions.
  • The module's own constants: declarations above the component's function.
  • What a shared module may import: other modules and packages, and what stays out.
  • When a shared file changes: the project graph, and what an edit compiles again.

Values from a .ts module

A shared module exports plain values, types and functions:

format.ts
// Pure helpers shared by components (ADR-0070): each output imports them from its copy of this
// module, and calls them in its template, its derived values and its listeners alike.
export function formatPrice(cents: number): string {
  return `€${(cents / 100).toFixed(2)}`;
}

export function pluralise(count: number, noun: string): string {
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
}

A component imports them like any module, and calls them in its template, its derived values and its listeners alike:

PriceTag.uf.tsx
import { computed, defineEmits, ref } from "unframework";

import { formatPrice, pluralise } from "./format";

export default function PriceTag({ cents }: { cents: number }) {
  const emit = defineEmits<{ order: [summary: string] }>();
  const quantity = ref(1);
  const total = computed(() => formatPrice(cents * quantity.value));

  function order() {
    emit("order", `${pluralise(quantity.value, "item")} for ${total.value}`);
  }

  return (
    <section class="price-tag" aria-label="Price">
      <p>Each: {formatPrice(cents)}</p>
      <p role="status">
        {pluralise(quantity.value, "item")}: {total.value}
      </p>
      <button type="button" onClick={() => quantity.value++}>
        One more
      </button>
      <button type="button" onClick={order}>
        Order
      </button>
    </section>
  );
}

Every output imports the same names from its own copy of format.ts, which sits beside it unchanged:

PriceTag.tsx
import { formatPrice, pluralise } from "./format";
import { type CSSProperties, useMemo, useRef, useState } from "react";

export interface PriceTagEvents {
  onOrder?: (summary: string) => void;
}

export interface PriceTagAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function PriceTag({
  cents,
  onOrder,
  className,
  style,
  ...attributes
}: { cents: number } & PriceTagEvents & PriceTagAttributes) {
  const [quantity, setQuantity] = useState(1);
  const quantityRef = useRef(quantity);
  const total = useMemo(() => formatPrice(cents * quantity), [cents, quantity]);

  function currentTotal() {
    return formatPrice(cents * quantityRef.current);
  }

  function order() {
    onOrder?.(`${pluralise(quantityRef.current, "item")} for ${currentTotal()}`);
  }

  return (
    <section
      className={cx("price-tag", className)}
      aria-label="Price"
      style={style}
      {...scopeAttributes(attributes)}
    >
      <p>Each: {formatPrice(cents)}</p>
      <p role="status">
        {pluralise(quantity, "item")}: {total}
      </p>
      <button
        type="button"
        onClick={() => {
          quantityRef.current++;
          setQuantity(quantityRef.current);
        }}
      >
        One more
      </button>
      <button type="button" onClick={order}>
        Order
      </button>
    </section>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}

/** The scope attributes of the styled parents that render this component, for its root. */
function scopeAttributes(props: object): Record<string, ""> {
  const scope: Record<string, ""> = {};
  for (const key of Object.keys(props)) {
    if (key.startsWith("data-uf-")) scope[key] = "";
  }
  return scope;
}

Angular's template sees only the class's members, so Angular also exposes each function it calls there as a protected readonly member, formatPrice = formatPrice. Qwik captures an imported value as a module-level import, which a $ scope may read.

Leave the extension off the import, ./format: each output imports its copy as written.

A module can hold types beside its values, and import another module. Each output tree holds a copy of every module the component reaches, here units.ts too:

units.ts
// The module `format.ts` reads: each output tree holds a copy of it too (ADR-0070).
export const UNITS = { km: "kilometres", mi: "miles" } as const;

export type Unit = keyof typeof UNITS;
format.ts
// A shared module that reads another one (ADR-0070).
import { UNITS } from "./units";
import type { Unit } from "./units";

export function formatDistance(value: number, unit: Unit): string {
  return `${value} ${UNITS[unit]}`;
}

The module's own constants

A component can also read the top-level const declarations of its own module: a list it renders, a frozen table, a plain function, or a constant that reads another:

Stepper.uf.tsx
import { ref } from "unframework";

// The module's own constants (ADR-0070): a list, a frozen table, a plain function and a constant
// that reads another, all read-only, read by the component's template and its listeners.
const STEPS = [1, 5, 10] as const;
const LIMITS = Object.freeze({ min: 0, max: 20 });
const caption = (value: number): string => `${value} of ${LIMITS.max}`;
const START = LIMITS.min + 2;

export default function Stepper() {
  const value = ref(START);

  function add(step: number) {
    value.value = Math.min(LIMITS.max, value.value + step);
  }

  return (
    <section class="stepper" aria-label="Stepper">
      <p role="status">{caption(value.value)}</p>
      <ul>
        {STEPS.map((step) => (
          <li key={step}>
            <button type="button" onClick={() => add(step)}>
              Add {step}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

The module computes them as it loads, on the server too, so each is checked as setup code is: a constant may change in place only what it builds itself, like a literal, a new or a copy.

Module values are read-only, so every target reads one value: plain functions, literals and frozen objects. Freezing one with Object.freeze is fine in the component's setup and client code too, since it changes nothing anyone reads.

What a shared module may import

A shared module may import other relative .ts modules, and packages. It may never import a framework, or unframework, whose ref and hooks exist only in .uf.tsx and .uf.ts files: every framework's output loads the copy, and one framework's import would break the six others. That's UF1204, at the component's import, even when the framework is a module further away:

tokens.ts
// A shared module: plain values, which every component's outputs read from their copy of it.
import { palette } from "./palette.ts";

export const tones = { info: palette.blue, danger: palette.red };
palette.ts
// A module the shared module reaches. It imports a framework, which no other target's output can
// load: keep it to plain values, and move what needs React into a composable in a `.uf.ts` module.
import { version } from "react";

export const palette = { blue: `blue-${version}`, red: "red" };

The component compiles for no target. Keep the module to plain values, and move logic that needs reactivity into a composable, which the compiler compiles for each framework.

A few things stay out for now:

  • An injection key lives in the .uf.tsx module that provides it, never a .ts module, since each framework's key is a different value (Context).
  • A module's function passed straight as a handler, onClick={copy}, is UF3029: a named handler is the component's own function. Call the module's function from an arrow, onClick={() => copy()}.
  • Module state that instances share has no form, since module values are read-only. Keep the state in a component, or in a composable each component calls.

When a shared file changes

A .ts module is read by every component that imports it, so an edit to it has to reach each of them, and nothing else. The compiler keeps a project graph for this: each module it compiled, what that module read, and the edges back from each file to the modules that read it. An edit then compiles again exactly the modules that depend on the file, and stops at any module whose inputs come out the same.

Let's start with four components and a props type. B reads the type from a.ts, C reaches it through the composable useC, and D reads nothing:

a.ts
export interface CardProps {
  label: string;
}
B.uf.tsx
import type { CardProps } from "./a.ts";
export default function B({ label }: CardProps) {
  return <p>{label}</p>;
}
useC.uf.ts
import { computed } from "unframework";
import type { CardProps } from "./a.ts";
export function useC(card: CardProps) {
  const text = computed(() => card.label);
  return { text };
}
C.uf.tsx
import { useC } from "./useC.uf.ts";
export default function C({ label }: { label: string }) {
  const { text } = useC({ label });
  return <p>{text.value}</p>;
}
D.uf.tsx
export default function D() {
  return <p>D</p>;
}

createProject, from @unframework/compiler, builds the graph. It reads files through your readFile, under root. It also takes a type oracle over the same files, which reads the types a module imports. The options compile() takes, like targets, go to each of its compiles, but filename and resolve, which are the project's.

Its compile(file) compiles a module, named relative to root, and keeps the result with what it read. Its update(files) takes the files that changed and gives back what it compiled again:

build.ts
import { readFile } from "node:fs/promises";

import { createProject, createSyntacticOracle } from "@unframework/compiler";

const root = process.cwd();
const read = (path: string) => readFile(path, "utf8").catch(() => undefined);
const project = createProject({
  root,
  readFile: read,
  typeOracle: createSyntacticOracle({ root, readFile: read }),
  targets: ["vue"],
});
for (const file of ["B.uf.tsx", "useC.uf.ts", "C.uf.tsx", "D.uf.tsx"]) {
  await project.compile(file);
}

// After adding `hint?: string` to `CardProps` in `a.ts`:
await project.update(["a.ts"]);
// { compiled: ["B.uf.tsx", "C.uf.tsx", "useC.uf.ts"], copied: ["a.ts"] }

A change to the type's shape compiles B, useC and C again, once each, and never D. C never reads a.ts itself: it reads useC's API, and that API reads a.ts, so the edit reaches C along the graph's edges. copied lists the shared modules whose copy changed, since every output tree holds a.ts beside its components.

An edit that leaves every shape alone, like a new export const LABEL = "Label"; in a.ts, compiles no module at all: update gives { compiled: [], copied: ["a.ts"] }. The project keeps a hash of each answer a module was given, its children's APIs and the shapes of the types it read included, and compiles a module again only where one of them changed. It still refreshes the copy of a.ts, so the outputs import the new text.

The Vite plugin compiles every file through one project, rooted at Vite's root, and you don't call createProject yourself. It tells Vite to watch every file the project says an output depends on: each child's .uf.tsx, each composable's .uf.ts, each shared .ts module, each stylesheet and each file a type was read from. So an edit to a.ts invalidates the modules of B and C, and leaves D's alone. A package's files in node_modules get no watch, since Vite's watcher ignores them, but the plugin compares them with what the project holds before every load, so a changed package file is never missed.

The decisions behind this page are ADR-0070 and, for the project graph, ADR-0071. Shared types are on Types and generics.

Copyright © 2026