Skip to content

Quick Start

App-Compose is built on two primitives: Task and Tag. Everything else — isolation, reuse, and predictable startup — flows from these core concepts. This guide teaches you how to use them.

A Task is a piece of logic that has its own identity and execution function. 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 mandatory 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.

Tasks cannot be called directly. To run a Task, first define it on a Stage. A Stage is a logical phase in your application’s workflow. This gives you precise control over the execution flow, prioritizing critical Tasks before non-essential ones.

After defining the Stages, call run() to start execution.

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()
.stage({ steps: [fetchUser] })
.run()

Steps within the same Stage run in parallel. Stages execute one after another — the next Stage starts only when all Steps in the current one have finished.

Stage 1 Stage 2 Stage 3
┌──────────┐ ┌──────────┐ ┌──────┐
│ A B C │ → │ D E │ → │ F │
└──────────┘ └──────────┘ └──────┘
parallel parallel parallel

Tasks rarely operate in a vacuum. To be dynamic and reusable, they need access to external data like a username, a product ID, or the current interface language. Tasks access data through a Context, receiving everything they need from the environment without hardcoding values. run.fn receives the resolved value of run.context.


To share data without linking tasks together, use Tag. A Tag is a placeholder for data that avoids direct imports between Tasks, keeping them isolated and reusable across any workflow.

import { createTag } from "@grlt-hub/app-compose"
const tag = createTag({ name: "userId" })

The bind operator fills a Tag at the Stage level, making data available for upcoming Tasks.

import { bind } from "@grlt-hub/app-compose"
.stage({ steps: [bind(tag, value)] })
.stage({ steps: [fetchUser] })

Have a look at the result:

import { bind, compose, createTag, createTask } from "@grlt-hub/app-compose"
const tag = createTag<number>({ name: "userId" })
const fetchUser = createTask({
name: "fetch-user",
run: {
context: { userId: tag.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()
.stage({ steps: [logIn] })
.stage({ steps: [bind(tag, logIn.result.id)] })
.stage({ steps: [fetchUser] })
.run()

Sometimes a value doesn’t come from another Task — it’s just a plain string, number, or object known upfront. To pass a static value, wrap it in literal.

This tells App-Compose that the value is data, not a reference to another Task or Tag. It also lets the library skip searching through complex structures like circular references or custom class instances — keeping your application fast.

literal works the same way everywhere: in context and in bind.

import { bind, compose, createTag, createTask, literal } from "@grlt-hub/app-compose"
const tag = createTag({ name: "userId" })
const fetchUser = createTask({
name: "fetch-user",
run: {
context: {
// 👇 Static value for context
baseUrl: literal("https://jsonplaceholder.typicode.com/users/"),
userId: tag.value,
},
fn: async (ctx) => {
const response = await fetch(`${ctx.baseUrl}/${ctx.userId}`)
const result = await response.json()
console.log(JSON.stringify(result, null, 2))
},
},
})
compose()
// 👇 Binding a Tag to a static value
.stage({ steps: [bind(tag, literal(1))] })
.stage({ steps: [fetchUser] })
.run()

Tasks are enabled by default. Use the enabled property to skip a Task based on feature toggles, application state, or any internal logic.

const tag = createTag({ name: "fetch-user::enabled" })
const fetchUser = createTask({
name: "fetch-user",
run: { fn: () => {...} },
enabled: {
context: { enabled: tag.value },
fn: ({ enabled }) => enabled,
},
})

The enabled property works as follows:

  • Data from enabled.context is passed to enabled.fn
  • enabled.fn must return a boolean | Promise<boolean>
  • If it returns false, the Task is skipped
const featureFlags = createTask({
name: "feature-flags",
run: {
fn: () => {
// 👇 true = Task runs, false = Task is skipped
const flags = { fetchingAllowed: true }
console.log(flags)
return flags
},
},
})
const tag = createTag({ name: "fetch-user::enabled" })
const fetchUser = createTask({
name: "fetch-user",
run: {
fn: () => console.log("[fetch-user]: user fetched"),
},
enabled: {
context: { enabled: tag.value },
// 👇 Return false to skip this Task
fn: ({ enabled }) => enabled,
},
})
compose()
.stage({ steps: [featureFlags] })
.stage({ steps: [bind(tag, featureFlags.result.fetchingAllowed)] })
.stage({ steps: [fetchUser] })
.run()

By now, you know how to build with App-Compose.


Next, head over to Installation to get everything set up.