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-ts/core';
export const { TaskList } = craftService(
{ name: 'TaskList', providedIn: 'function' },
function* () {
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[]>(),
}));
},
});
},
);
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 | undefined
TIP
value() is safe to read in templates and computed signals: it returns undefined when the query has no resolved value.
In the template#
ifNode / matchNode 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, forNode, li, p, ul } from '@craft-ts/component';
export const Tasks = craftComponent('Tasks', {}, function* () {
// The service exposes its query under its name.
const { tasksQuery: tasks } = yield* TaskList();
return tasks.isLoading()
? p('Loading…')
: tasks.hasException()
? p('Could not load tasks.')
: ul(
forNode(
() => tasks.value() ?? [],
{ track: (task) => task.id },
(task) => li(task.title),
),
);
});
When the branches depend on an exception code rather than a boolean, reach for matchNode.exhaustive(...) — the compiler then checks you covered every code:
matchNode.exhaustive(() => tasks.exceptions().loader, '_tag', {
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:
import { CraftHttpClient, craftService, query } from '@craft-ts/core';
export const { TaskSearch } = craftService(
{ name: 'TaskSearch', providedIn: 'function' },
function* () {
yield* query('searchQuery', {
method: (term: string) => term,
loader: function* ({ params: term }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/tasks?q=${term}`,
success: response<Task[]>(),
}));
},
});
},
);
Adding derived values#
Same insertion mechanism as step 2 — third argument:
const { tasksQuery } =
yield *
query(
'tasksQuery',
{
/* … */
},
({ value, isLoading }) => ({
count: craftUse(craftComputed('count', function* () {
return (yield* value())?.length ?? 0;
})),
isEmpty: craftUse(craftComputed('isEmpty', function* () {
return !(yield* isLoading()) && (yield* value())?.length === 0;
})),
}),
);
yield* 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.