High coupling
Change one feature — and you’re touching three others.
Scaling a frontend app is easy. Keeping it under control is not. Features develop high coupling, startup order becomes unpredictable, and things break in unexpected places. Every team works around these problems differently.
High coupling
Change one feature — and you’re touching three others.
Reuse means copy-paste
You need the same feature, just slightly different — so you duplicate it.
Silent breaks
You changed something small and the app broke somewhere else — no clear error.
What's running here?
You open a page and have no idea which features are active on it.
No control over startup order
Startup order is a guess — non-critical stuff runs first, the important parts wait. You find out it’s wrong at runtime.
Performance black box
No idea which initialization step is slow — or whether your last change made it worse.
App-Compose is a TypeScript library that structures your frontend app around isolated tasks, explicit context, and predictable startup — by design.
import {bind, compose, createTag, createTask} from "@grlt-hub/app-compose"
const user = createTask({ name: "user", run: { fn: () => ({ id: 14 }) },})
const userId = createTag<number>({ name: "userId" })
const analytics = createTask({ name: "analytics", run: { // 👇 isolated: depends on a tag, not on "user" context: userId.value, fn: (userId) => console.log(`Analytics ready. User ID: ${userId}`), },})
compose() .stage({ steps: [user] }) .stage({ steps: [bind(userId, user.result.id)] }) .stage({ steps: [analytics] }) .run()import {bind, compose, createTag, createTask, literal} from "@grlt-hub/app-compose";
const label = createTag<string>({ name: "label" })
// 👇 same task — different context each timeconst greet = createTask({ name: "greet", run: { context: label.value, fn: (label) => console.log(`Hello, ${label}!`), },})
compose() .stage({ steps: [bind(label, literal("Alice"))] }) .stage({ steps: [greet] }) .run()
compose() .stage({ steps: [bind(label, literal("Bob"))] }) .stage({ steps: [greet] }) .run()const label = createTag({ name: "label" })
const greet = createTask({ name: "greet", run: { context: label.value, fn: (label) => console.log(`Hello, ${label}!`), },})
test("catches missing context before runtime", () => { expect(() => compose() // 👇 uncomment me //.stage({ steps: [bind(label, literal("World"))] }) .stage({ steps: [greet] }) .guard(), ).not.toThrow()})import {bind, compose, createTag, createTask, optional} from "@grlt-hub/app-compose"
const tag = createTag<string>({ name: "title" })
const alpha = createTask({ name: "alpha", run: { fn: () => ({ list: [0], title: "hello" }) },})
const beta = createTask({ name: "beta", run: { context: { title: tag.value }, fn: console.log, },})
const gamma = createTask({ name: "gamma", run: { context: { list: optional(alpha.result.list) }, fn: console.log, },})
const graph = compose() .stage({ steps: [alpha] }) .stage({ steps: [bind(tag, alpha.result.title)] }) .stage({ steps: [beta, gamma] }) // 👇 use .graph() instead of .run() to get the dependency graph .graph()
console.log(JSON.stringify(graph, null, 2))import { compose, createTask } from "@grlt-hub/app-compose"
const auth = createTask({ name: "auth", run: { fn: () => console.log("auth"), },})
const ui = createTask({ name: "ui", run: { fn: () => console.log("ui") },})
const metrics = createTask({ name: "metrics", run: { fn: () => console.log("metrics") },})
compose() // 👇 runs first .stage({ steps: [auth] }) // 👇 runs after — ui and metrics run in parallel .stage({ steps: [ui, metrics] }) .run()import { compose, createTask, type ComposeLogger } from "@grlt-hub/app-compose"
const auth = createTask({ name: "auth", run: { fn: async () => { console.log("auth: verifying session token...") await sleep(80) }, },})
const ui = createTask({ name: "ui", run: { fn: async () => { console.log("ui: mounting root component...") await sleep(40) }, },})
const createLogger = (name: string): ComposeLogger => { let start = 0
return { onStageStart: () => (start = performance.now()), onStageComplete: () => { const ms = (performance.now() - start).toFixed(1) console.log(`stage ${name}: ${ms}ms`) }, }}
compose() .stage({ steps: [auth], logger: createLogger("auth") }) .stage({ steps: [ui], logger: createLogger("ui") }) .run()
// Chrome throttles setTimeout in cross-origin iframes (Sandpack),// so we use MessageChannel insteadconst sleep = (ms: number) => new Promise<void>((resolve) => { const start = performance.now() const ch = new MessageChannel() const tick = () => { if (performance.now() - start >= ms) resolve() else { ch.port1.onmessage = tick ch.port2.postMessage(null) } } ch.port1.onmessage = tick ch.port2.postMessage(null) })