Quick Start
App-Compose is built on the three key pieces: Task, Tag, and Wire. A Task does the work. A Tag holds a value. A Wire supplies it.
No magic, no copy-paste, no surprises at startup — everything else flows from these three. Five short steps follow.
How to create a Task
Section titled “How to create a Task”A Task is one unit of work — it has a name and a function to run. A Task can be as small as a single API request, or as large as a product feature.
const fetchUser = createTask({ name: "fetch-user", run: { fn: async () => { const response = await fetch("https://jsonplaceholder.typicode.com/users/1") const result = await response.json()
console.log(JSON.stringify(result, null, 2)) return result }, },})Note that every Task must have a name and a run.fn. These are the only required properties. The run.fn can be either a synchronous or an asynchronous function. It can return a value to be used by other Tasks, or return nothing if the Task only performs a side effect.
How to run a Task
Section titled “How to run a Task”To run a Task, place it in a Step. A Step is a position in the run order. Steps let you say what runs before what, and what runs together.
Steps follow two rules:
- Each
.step()runs after the previous one..step(a).step(b)— firsta, thenb. - Passing an array runs Tasks in parallel.
.step([a, b])—aandbrun at the same time.
.step(alpha).step([beta, gamma]).step(delta)
// is equivalent to:alpha() .then(() => Promise.all([beta(), gamma()])) .then(() => delta())Once all the Steps are listed, all that’s left is to call run().
import { compose, createTask } from "@grlt-hub/app-compose"
const fetchUser = createTask({ name: "fetch-user", run: { fn: async () => { const response = await fetch("https://jsonplaceholder.typicode.com/users/1") const result = await response.json()
console.log(JSON.stringify(result, null, 2)) return result }, },})
compose() // 👇 define .step(fetchUser) // 👇 run .run()How to pass data to a Task
Section titled “How to pass data to a Task”Most Tasks need data from outside — a username, a product ID, the current locale. To stay reusable, they read it through a Context (a property of the Task config) instead of hardcoding values. run.fn receives whatever run.context resolves to.
const fetchUser = createTask({ name: "fetch-user", run: { context: { id: userId.value }, fn: (ctx) => console.log(`Fetching user ${ctx.id}`), },})There are two ways to fill it.
Through a Tag
Section titled “Through a Tag”A Tag is a placeholder for data. A Task reads it through tag.value. A createWire supplies that value — so the Task never knows where it came from.
.step(logIn).step(createWire({ from: logIn.result.id, to: userId })).step(fetchUser)
// is equivalent to:[ logIn.result.id ] => [ userId ] => [ fetchUser reads userId.value ] (source) (Tag) (reader)Have a look at the result:
import { createWire, compose, tag, createTask } from "@grlt-hub/app-compose"
const userId = tag<number>("userId")
const fetchUser = createTask({ name: "fetch-user", run: { context: { userId: userId.value }, fn: async (ctx) => { const response = await fetch( // 👇 userId is passed from Context `https://jsonplaceholder.typicode.com/users/${ctx.userId}`, ) const result = await response.json()
console.log(JSON.stringify(result, null, 2)) }, },})
const logIn = createTask({ name: "log-in", run: { fn: () => { // 👇 Try different values here return { id: 1 } }, },})
compose() .step(logIn) .step(createWire({ from: logIn.result.id, to: userId })) .step(fetchUser) .run()For values you already know upfront — a primitive or object — wrap them in literal. That tells App-Compose the value is data, not a reference to another Task or Tag.
import { literal } from "@grlt-hub/app-compose"
.step(createWire({ from: literal(42), to: userId }))The path is the same — only the source changes.
Task to Task
Section titled “Task to Task”One Task reads another’s result directly — no Tag, no Wire. The two are now coupled. Reasonable when they always run together; otherwise, prefer a Tag.
import { compose, createTask } from "@grlt-hub/app-compose"
const logIn = createTask({ name: "log-in", run: { fn: () => ({ userId: 1 }), },})
const fetchUser = createTask({ name: "fetch-user", run: { context: { // 👇 directly coupled to logIn userId: logIn.result.userId, }, fn: async (ctx) => { const response = await fetch(`https://jsonplaceholder.typicode.com/users/${ctx.userId}`) const result = await response.json() console.log(JSON.stringify(result, null, 2)) }, },})
compose() .step(logIn) .step(fetchUser) .run()How to disable a Task
Section titled “How to disable a Task”Every Task is enabled by default. To skip one based on a feature flag, the current user, or any other state — add an enabled block next to run.
enabled has the same shape as run, but enabled.fn returns boolean | Promise<boolean>. A false result skips the Task.
import { compose, createTask } from "@grlt-hub/app-compose"
const featureFlags = createTask({ name: "feature-flags", run: { fn: () => { // 👇 true = Task runs, false = Task is skipped const flags = { logInAllowed: true } console.log(JSON.stringify(flags, undefined, 2))
return flags }, },})
const logIn = createTask({ name: "log-in", run: { fn: () => console.log("[log-in]: user logged"), }, enabled: { context: featureFlags.result.logInAllowed, // 👇 Return false to skip this Task fn: (enabled) => enabled, },})
compose() .step(featureFlags) .step(logIn) .run()When a Task fails or is skipped
Section titled “When a Task fails or is skipped”When a Task fails or is skipped, every Task that reads from it is skipped too. The path doesn’t matter — Tag or direct, the result is the same. Your app keeps running.
import { compose, createTask, createWire, tag } from "@grlt-hub/app-compose"
const userId = tag<number>("userId")
const logIn = createTask({ name: "log-in", run: { fn: () => { // 👇 uncomment to make logIn fail // throw new Error("oops") return { userId: 1 } }, }, // 👇 or uncomment to skip logIn instead // enabled: { fn: () => false },})
// reads through a Tagconst fetchUser = createTask({ name: "fetch-user", run: { context: userId.value, fn: () => {}, },})
// reads directlyconst loadCart = createTask({ name: "load-cart", run: { context: logIn.result.userId, fn: () => {}, },})
// independent — runs no matter whatconst sendAnalytics = createTask({ name: "send-analytics", run: { fn: () => {} },})
// 👇 control-task pattern. More in /guides/const controlTask = createTask({ name: "control-task", run: { context: { [logIn.name]: logIn.status, [fetchUser.name]: fetchUser.status, [loadCart.name]: loadCart.status, [sendAnalytics.name]: sendAnalytics.status, }, fn: (ctx) => { Object.entries(ctx).forEach(([name, status]) => { console.log(`${name} status: ${status}`) }) }, },})
compose() .step(logIn) .step(createWire({ from: logIn.result.userId, to: userId })) .step([fetchUser, loadCart, sendAnalytics]) .step(controlTask) .run()An optional modifier lets a Task keep running even when its source fails or skips. That’s a topic for the guides.
Next Steps
Section titled “Next Steps”The guides cover more. But first, set up your project — head to Installation.