Composables
Composables let components share the logic behind what they do, like opening and closing, tracking a media query or a value a parent may control, without sharing any markup. In Unframework a composable is a use* function in a .uf.ts module, written with the same ref, computed and hooks as a component's setup. Think of a disclosure, a menu and a dialog that each keep their own copy of the same open state and drift apart, a hook that reads window on the server and throws, or a "shared" helper that only React users can call.
The compiler turns each composable into one module per framework, in that framework's own idiom, so a design system can ship it to people who write their components by hand. This page walks you through writing and calling one. We'll look at:
- A composable and its call: the
.uf.tsmodule, and what each framework receives. - Arguments: values, getters and callbacks.
- What a caller may do: read what a composable returns, and never write it.
- Effects across the call: where they run, and where React and Solid differ.
A composable and its call
A .uf.ts module holds named exports whose names start with use and an upper-case letter. Each one is a setup body, under a component's rules, with no JSX, and its last statement returns a shorthand object of what callers may use:
// A composable shared by every component that opens and closes something (ADR-0069): a ref of
// state, and the functions that change it.
import { ref } from "unframework";
export function useToggle(initial: boolean) {
const on = ref(initial);
function toggle() {
on.value = !on.value;
}
function close() {
on.value = false;
}
return { on, toggle, close };
}
A component calls it at the top level of its setup, and destructures the result:
import { useToggle } from "./useToggle.uf.ts";
export default function Disclosure() {
const { on, toggle, close } = useToggle(false);
return (
<section class="disclosure" aria-label="Shipping">
<button type="button" aria-expanded={on.value} onClick={() => toggle()}>
Shipping details
</button>
{on.value && (
<div class="panel">
<p>Orders ship within two days.</p>
<button type="button" onClick={() => close()}>
Close
</button>
</div>
)}
</section>
);
}
Each call runs the composable's setup once, where the call sits, with state of its own. So two components that call useToggle never share on, and neither do two calls in one component (composables/use-media-query calls one twice, under two names). Each framework gets its own module:
import { useRef, useState } from "react";
export function useToggle(initial: boolean) {
const [on, setOn] = useState(initial);
const onRef = useRef(on);
const [currentOn] = useState(() => () => onRef.current);
const [toggle] = useState(() => () => {
onRef.current = !onRef.current;
setOn(onRef.current);
});
const [close] = useState(() => () => {
onRef.current = false;
setOn(onRef.current);
});
return { on, currentOn, toggle, close };
}
And each caller reads the result its framework's way:
import { useToggle } from "./useToggle";
import type { CSSProperties } from "react";
export interface DisclosureAttributes {
className?: string;
style?: CSSProperties;
}
export default function Disclosure({ className, style, ...attributes }: DisclosureAttributes) {
const { on, toggle, close } = useToggle(false);
return (
<section
className={cx("disclosure", className)}
aria-label="Shipping"
style={style}
{...scopeAttributes(attributes)}
>
<button type="button" aria-expanded={on} onClick={() => toggle()}>
Shipping details
</button>
{on ? (
<div className="panel">
<p>Orders ship within two days.</p>
<button type="button" onClick={() => close()}>
Close
</button>
</div>
) : null}
</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;
}
| Target | Module | The caller reads |
|---|---|---|
| React | useToggle.ts, a custom hook | on in render, and currentOn() in client code |
| Vue | useToggle.ts, with shallowRef | on, as written |
| Svelte | useToggle.svelte.ts, with runes | toggle.on, keeping the object, since runes don't survive destructuring |
| Angular | use-toggle.ts, with signals | a field, toggle_1.on(), with read-only signals |
| Solid | useToggle.ts, with signals | on() |
| Qwik | useToggle.ts, a use* hook | on.value, and calls its functions, which are $() QRLs |
| Astro | useToggle.ts, static | the initial values, with client code dropped |
Astro renders on the server only, so its composable keeps the state's initial value and nothing that runs in the browser.
React's live readers
A React hook runs again on every render, so on is the value of the render that's on screen. For each state and derived value it returns, the hook also returns a live reader, currentOn, which reads the hook's latest write. A caller's client code calls it, so code that writes and then reads sees the write right away:
import { defineEmits } from "unframework";
import { useCart } from "./useCart.uf.ts";
export default function Cart() {
const emit = defineEmits<{
total: [items: number];
cleared: [empty: boolean];
}>();
const { items, empty, add, clear } = useCart();
function addTwiceAndReport() {
add(1);
add(2);
emit("total", items.value);
}
function clearAndReport() {
clear();
emit("cleared", empty.value);
}
return (
<section class="cart" aria-label="Cart">
<p role="status">Items: {items.value}</p>
<button type="button" onClick={addTwiceAndReport}>
Add three
</button>
<button type="button" onClick={clearAndReport}>
Clear
</button>
</section>
);
}
import { useMemo, useRef, useState } from "react";
export function useCart() {
const [items, setItems] = useState(0);
const itemsRef = useRef(items);
const [currentItems] = useState(() => () => itemsRef.current);
const empty = useMemo(() => items === 0, [items]);
const [currentEmpty] = useState(() => () => itemsRef.current === 0);
const [add] = useState(() => (count: number) => {
itemsRef.current += count;
setItems(itemsRef.current);
});
const [clear] = useState(() => () => {
itemsRef.current = 0;
setItems(itemsRef.current);
});
return { items, currentItems, empty, currentEmpty, add, clear };
}
addTwiceAndReport emits 3 on every target that runs client code. React's output reads currentItems() there, where the source reads items.value.
Arguments
Annotate every parameter: its type says how the composable reads it.
- A value is read once, when the call runs, like
initialabove. - A getter,
() => T, is read reactively, so every derived value and watcher that reads it follows what the caller's getter reads. - A callback, any other function type, is called from client code, in the caller's scope, so it may write the caller's state and emit.
Here both arguments are getters, one over a prop and one over the caller's state, so the total follows both:
// A composable whose getter parameters it reads reactively (ADR-0069): the total follows every
// change of what the caller's getters read, a prop or the caller's own state.
import { computed } from "unframework";
export function useTotal(price: () => number, quantity: () => number) {
const total = computed(() => price() * quantity());
const label = computed(() => `${quantity()} × ${price()} = ${total.value}`);
return { total, label };
}
import { ref } from "unframework";
import { useTotal } from "./useTotal.uf.ts";
export default function Basket({ price }: { price: number }) {
const quantity = ref(1);
const { total, label } = useTotal(
() => price,
() => quantity.value,
);
return (
<section class="basket" aria-label="Basket">
<p role="status">Total: {total.value}</p>
<p>{label.value}</p>
<button type="button" onClick={() => quantity.value++}>
Add one
</button>
</section>
);
}
Qwik passes a getter as a useComputed$, and Angular calls a composable whose getter reads an input from ngOnInit, in the component's injection context, once its inputs are set:
import { type ReadonlySignal, useComputed$ } from "@qwik.dev/core";
export function useTotal(price: ReadonlySignal<number>, quantity: ReadonlySignal<number>) {
const total = useComputed$(() => price.value * quantity.value);
const label = useComputed$(() => `${quantity.value} × ${price.value} = ${total.value}`);
return { total, label };
}
A callback lets the composable tell its caller something. This counter calls back when it reaches its limit, and the caller writes its own state and emits:
import { defineEmits, ref } from "unframework";
import { useCounter } from "./useCounter.uf.ts";
export default function Clicker() {
const emit = defineEmits<{ limit: [count: number] }>();
const message = ref("Keep going");
const { count, step, increment, reset } = useCounter(3, (reached) => {
message.value = `Reached ${reached}`;
emit("limit", reached);
});
return (
<section class="clicker" aria-label="Clicker">
<p role="status">Count: {count.value}</p>
<p>{message.value}</p>
<button type="button" onClick={() => increment()}>
Add {step}
</button>
<button type="button" onClick={() => reset()}>
Reset
</button>
</section>
);
}
A composable can be generic too. composables/use-controllable-state holds a value a parent may control, with a getter for the parent's value, a value for the initial one and a callback for each change.
Passing a prop or state straight to a value parameter, useTotal(price, …), is UF2007: the composable would keep the value it had when the setup ran. Wrap it in a getter, () => price.
What a caller may do
A caller reads a returned ref or derived value through .value, and never writes it: on some targets, such as React, the caller holds the value its render reads apart from the composable's state, so a write would change one and not the other. Writing one is UF2036, here at on.value = true:
// UF2036 composable-result-write: the component writes a ref its composable returns. The
// composable owns its state, and on React and Svelte the caller holds no ref to write: change
// it through a function the composable returns, `toggle()`.
import { useToggle } from "./useToggle.uf.ts";
export default function Switch() {
const { on, toggle } = useToggle(false);
function turnOn() {
on.value = true;
}
return (
<div class="switch">
<button type="button" onClick={() => toggle()}>
Toggle
</button>
<button type="button" onClick={turnOn}>
{on.value ? "On" : "Off"}
</button>
</div>
);
}
Change the state through a function the composable returns instead, as the first button does with toggle().
The contract is narrow for now. UF2035 rejects a composable that:
- calls
useId,useTemplateRef,provide,inject, adefine*macro or another composable; - is a default export, or isn't named
useand an upper-case letter; - returns anything but a shorthand object.
Move what needs them into the component that calls the composable, and pass it in as an argument. An injection key stays in the .uf.tsx module that provides it (Context).
Effects across the call
A composable's watchers and lifecycle hooks take the call's place among the caller's own, in source order. A mounted hook the caller declares before the call runs before the composable's, and one declared after it runs after:
// A composable with a watcher and a mounted hook (ADR-0069), each reporting through its caller's
// callback, so the caller can see where they run among its own.
import { onMounted, ref, watch } from "unframework";
export function useTracked(report: (entry: string) => void) {
const count = ref(0);
watch(count, () => report("composable watcher"));
onMounted(() => report("composable mounted"));
function increment() {
count.value++;
}
return { count, increment };
}
import { onMounted, ref, watch } from "unframework";
import { useTracked } from "./useTracked.uf.ts";
export default function Timeline() {
const entries = ref<string[]>([]);
function record(entry: string) {
entries.value = [...entries.value, entry];
}
onMounted(() => record("caller mounted first"));
const { count, increment } = useTracked((entry) => record(entry));
watch(
() => count.value,
() => record("caller watcher"),
);
onMounted(() => record("caller mounted last"));
return (
<section class="timeline" aria-label="Timeline">
<p role="status">Count: {count.value}</p>
<button type="button" onClick={() => increment()}>
Increment
</button>
<ol>
{entries.value.map((entry, index) => (
<li key={index}>{entry}</li>
))}
</ol>
</section>
);
}
That holds on Vue, Svelte, Angular and Qwik. React and Solid can't keep it, so they declare it, for information, with UF4005 at the call:
- React runs a component's effects in its own order, and a custom hook's effects can't join its caller's.
- Solid queues a composable's watchers in the composable's own module, apart from the caller's.
On Astro no effect runs at all. The output still runs on React and Solid, so make each effect depend on the state it reads, not on when another effect ran. A composable's onUnmounted hooks run when the component that called it unmounts, once for each call (semantics/composable-unmount-order). These rules are in the semantics contract.
The decision behind this page is ADR-0069. Plain values and functions, which need no compiling, are on Shared modules.
Types and generics
Props types can live in their own files, in a child's module or in a package. A component can be generic over its items, and every type a module exports reaches its consumers in every framework.
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.