5. Load server data
Goal: replace the hand-rolled load() from step 4 with query, and get loading, error and exception state for free.
The query primitive
import { CraftHttpClient, craftService, query } from '@craft-ng/core';
export const { TaskList } = craftService(
{ name: 'TaskList', scope: 'function' },
function* () {
const tasksQuery = yield* query('tasksQuery', {
// The initial params value immediately triggers the loader.
params: () => ({ done: false }),
loader: function* ({ params }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/tasks?done=${params.done}`,
success: response<Task[]>(),
}));
},
});
return tasksQuery;
},
);Three things to read here.
params is reactive. When what it returns changes, the loader re-runs. It can be a signal, a function, or a generator that yields other services.
loader is a generator, so it can yield* — here CraftHttpClient, which is the craft-tracked HTTP client. A plain async function works too when there is nothing to yield.
The result is a ref carrying the full async state:
tasksQuery.value(); // Task[] | undefined — never throws
tasksQuery.isLoading(); // boolean
tasksQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'
tasksQuery.exception(); // craftException | undefinedTIP
value() is safe to read in templates and computed signals: it returns undefined when the query has no resolved value.
In the template
ifBlock / matchBlock are the structural conditionals (see step 2). For a first pass a ternary chain reads fine — just remember it makes the branch invisible to the type-level assertions:
import { craftComponent, each, li, p, ul } from '@craft-ng/component';
export const Tasks = craftComponent(
'Tasks',
{},
function* () {
const tasks = yield* TaskList();
return { tasks };
},
({ tasks }) =>
tasks.isLoading()
? p('Loading…')
: tasks.hasException()
? p('Could not load tasks.')
: ul(
each(
() => tasks.value() ?? [],
{ track: (task) => task.id },
(task) => li(task.title),
),
),
);When the branches depend on an exception code rather than a boolean, reach for matchBlock.exhaustive(...) — the compiler then checks you covered every code:
matchBlock.exhaustive(() => tasks.exceptions().loader, 'code', {
TASKS_FORBIDDEN: () => p('You do not have access to this list.'),
TASKS_NOT_FOUND: () => p('This list no longer exists.'),
});See Exceptions as values.
Triggering it yourself
params re-runs the loader automatically. When the trigger is a user action instead, use method:
const { searchQuery } =
yield *
query('searchQuery', {
method: (term: string) => term,
loader: function* ({ params: term }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/tasks?q=${term}`,
success: response<Task[]>(),
}));
},
});
yield * searchQuery.call('angular');Adding derived values
Same insertion mechanism as step 2 — third argument:
const { tasksQuery } =
yield *
query(
'tasksQuery',
{
/* … */
},
({ value, isLoading }) => ({
count: computed(() => value()?.length ?? 0),
isEmpty: computed(() => !isLoading() && value()?.length === 0),
}),
);
tasksQuery.count();About the flicker
There isn't one: when params change, the previous value stays on screen until the new one resolves. That is the default, so paginating never blanks the list.
If you actually want the value cleared while loading, opt out explicitly:
query('tasksQuery', {
params: () => ({ page: page() }),
preservePreviousValue: () => false,
loader: /* … */,
});What you gained
Server state with the same shape as local state — named, insertable, tracked — and no manual isLoading flag.
Beyond the basics
Parallel queries per identifier, business exceptions raised from params, typed HTTP exception matchers, and reacting to mutations are all on query.