Skip to content

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.

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.

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:

  1. Each .step() runs after the previous one. .step(a).step(b) — first a, then b.
  2. Passing an array runs Tasks in parallel. .step([a, b])a and b run 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()

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.

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.

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()

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, 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 Tag
const fetchUser = createTask({
name: "fetch-user",
run: {
context: userId.value,
fn: () => {},
},
})
// reads directly
const loadCart = createTask({
name: "load-cart",
run: {
context: logIn.result.userId,
fn: () => {},
},
})
// independent — runs no matter what
const 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.

The guides cover more. But first, set up your project — head to Installation.