Composables
Functions that return signals, the Signy answer to hooks.
A composable is a plain function. It creates signals and effects, and
because a component calls it from setup(), the component
owns them and disposes them on unmount. There are no rules about call
order, as there are for React hooks.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Composables - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Signal, application, html, effect, onCleanup, Show, For, DOM } = Signy;
// A composable is a function that creates signals and effects. Called from a
// component's setup(), they belong to the component and go away with it.
function useLocalStorage(key, initial) {
let stored;
try {
stored = JSON.parse(localStorage.getItem(key));
} catch {}
const value = new Signal(stored ?? initial);
effect(() => {
try {
localStorage.setItem(key, JSON.stringify(value.value));
} catch {}
});
return value;
}
function useDebounced(source, ms) {
const value = new Signal(source());
effect(() => {
const next = source();
const timer = setTimeout(() => (value.value = next), ms);
onCleanup(() => clearTimeout(timer));
});
return value;
}
function useMediaQuery(query) {
const list = matchMedia(query);
const matches = new Signal(list.matches);
DOM.on(list, "change", () => (matches.value = list.matches));
return matches;
}
function useMouse(log) {
const position = new Signal({ x: 0, y: 0 });
DOM.on(window, "pointermove", (event) => (position.value = { x: event.clientX, y: event.clientY }));
effect(() => onCleanup(() => log("useMouse removed its pointermove listener")));
return position;
}
function useInterval(callback, ms, log) {
effect(() => {
const timer = setInterval(callback, ms);
onCleanup(() => {
clearInterval(timer);
log("useInterval cleared its timer");
});
});
}
const log = new Signal([]);
const write = (line) => log.set((lines) => [...lines.slice(-3), line]);
class Tracker extends Component {
seconds = new Signal(0);
setup() {
this.mouse = useMouse(write);
useInterval(() => this.seconds.set((s) => s + 1), 1000, write);
}
render() {
return html`
<p>Pointer at ${() => this.mouse.value.x}, ${() => this.mouse.value.y}. Mounted for ${() => this.seconds.value}s.</p>
`;
}
}
class Playground extends Component {
tracking = new Signal(true);
setup() {
this.userName = useLocalStorage("signy-composables-name", "Ada");
this.query = new Signal("");
this.debounced = useDebounced(() => this.query.value, 400);
this.wide = useMediaQuery("(min-width: 40rem)");
this.dark = useMediaQuery("(prefers-color-scheme: dark)");
}
render() {
return html`
<h1>Composables</h1>
<article class="card">
<header><strong>useLocalStorage</strong></header>
<label for="name">Your name, kept after a reload</label>
<input id="name" value=${this.userName.peek()} onInput=${(e) => (this.userName.value = e.currentTarget.value)} />
<p>Hello, ${() => this.userName.value}!</p>
</article>
<article class="card">
<header><strong>useDebounced</strong></header>
<label for="query">Type quickly</label>
<input id="query" onInput=${(e) => (this.query.value = e.currentTarget.value)} />
<p>Debounced: "${() => this.debounced.value}"</p>
</article>
<article class="card">
<header><strong>useMediaQuery</strong></header>
<p>The window is ${() => (this.wide.value ? "wide" : "narrow")}, the system theme is ${() => (this.dark.value ? "dark" : "light")}. Resize to see it change.</p>
</article>
<article class="card">
<header><strong>useMouse and useInterval</strong></header>
<label>
<input type="checkbox" role="switch" checked onChange=${(e) => (this.tracking.value = e.currentTarget.checked)} />
Mount the tracker
</label>
${Show({ when: () => this.tracking.value, children: () => html`<${Tracker} />` })}
<ul aria-label="Cleanup log">
${For({ each: () => log.value, children: (line) => html`<li><small>${line}</small></li>` })}
</ul>
</article>
`;
}
}
application(Playground).mount("#app");
</script>
</body>
</html>
What it uses
- effect() and onCleanup()
- setup() as the place to call them
- Signals as return values