query
query fetches data and owns its whole lifecycle — loading, resolved, exception — re-running itself when its inputs change.
Use it when you display data that lives on a server. Not when you write to the server (mutation) or run a one-off async action that isn't a fetch (asyncProcess).
One source of truth
Don't copy a query's result into a state. The query is the state.
The common case
import { CraftHttpClient, query } from '@craft-ng/core';
const { userQuery } =
yield *
query('userQuery', {
params: () => ({ userId: currentUserId() }),
loader: function* ({ params }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/users/${params.userId}`,
success: response<User>(),
}));
},
});params is reactive: when what it returns changes, the loader runs again. The result carries the full async state:
userQuery.value(); // User | undefined — never throws
userQuery.isLoading(); // boolean
userQuery.status(); // 'idle' | 'loading' | 'resolved' | 'exception'
userQuery.exception(); // craftException | undefinedTIP
value() is safe to read in templates and computed signals: it returns undefined when the query has no resolved value.
Triggering it yourself
When the trigger is a user action rather than a reactive input, use method instead of params:
const { searchQuery } =
yield *
query('searchQuery', {
method: (term: string) => term,
loader: function* ({ params: term }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/search?q=${term}`,
success: response<Array<{ id: string; title: string }>>(),
}));
},
});
// In a tracked generator, consume the trigger with yield*.
yield * searchQuery.call('angular');From an ordinary UI callback, the imperative form remains valid: click: () => searchQuery.call(term). Do not put either form in a craftEffect dependency graph; use reactive params for data loading.
Adding derived values
Same insertion mechanism as any primitive:
const { todosQuery } =
yield *
query(
'todosQuery',
{
params: () => ({ completed: showCompleted() }),
loader: async ({ params }) =>
(await fetch(`/api/todos?completed=${params.completed}`)).json(),
},
({ value, isLoading }) => ({
count: computed(() => value()?.length ?? 0),
isEmpty: computed(() => !isLoading() && value()?.length === 0),
}),
);
todosQuery.count();An insertion can also be a function* when it needs to yield services.
Enriching every item in a list
When a query returns an array, insertQuerySelect attaches an insertion to each selected item. The selector keeps the item type, so derived values can use its properties without casting:
import { computed } from '@angular/core';
import { CraftHttpClient, insertQuerySelect, query } from '@craft-ng/core';
type User = {
id: string;
firstName: string;
lastName: string;
role: 'admin' | 'member';
};
const { usersQuery } =
yield *
query(
'usersQuery',
{
params: () => ({ teamId: currentTeamId() }),
loader: function* ({ params }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/teams/${params.teamId}/users`,
success: response<User[]>(),
}));
},
},
insertQuerySelect('user', ({ state }) => ({
displayName: computed(() => `${state().firstName} ${state().lastName}`),
roleLabel: computed(() =>
state().role === 'admin' ? 'Administrator' : 'Member',
),
})),
);
// `selectUser` targets one item in the returned array.
const firstUser = usersQuery.selectUser(0);
firstUser?.displayName(); // 'Ada Lovelace'
firstUser?.roleLabel(); // 'Administrator'The same pattern supports selecting a nested object property with insertQuerySelect, while preserving the selected property's type.
Avoiding the flicker when inputs change
This is already the default. When params change, the previous value stays visible until the new one resolves, so a paginated list never blanks out mid-navigation.
You only touch the option to turn it off:
query('postsQuery', {
params: () => ({ page: currentPage() }),
preservePreviousValue: () => false, // clear the value while loading
loader: async ({ params }) =>
(await fetch(`/api/posts?page=${params.page}`)).json(),
});Not consulted for parallel queries
With an identifier, each key keeps its own resource, so there is no "previous value" to preserve — the option is ignored on that path.
Reacting to a mutation
Rather than reloading by hand after a write, declare the link:
import {
insertQueryPipe,
insertReactOnMutation,
insertStoragePersister,
} from '@craft-ng/core';
const userQuery = yield* query(
'userQuery',
{
params: () => ({ userId: currentUserId() }),
loader: /* … */,
},
insertQueryPipe(
insertReactOnMutation(updateUserMutation, {
// apply the change immediately, before the server answers
optimisticPatch: {
name: ({ mutationParams }) => mutationParams.name,
email: ({ mutationParams }) => mutationParams.email,
},
// and go get the truth back if the mutation failed
reload: { onMutationException: true },
}),
insertStoragePersister({
storeName: 'demo-app',
key: 'user-query',
}),
),
);Full options on Reacting to mutations.
Exceptions
exceptions() is split by origin and typed from the codes you declared — params for what your method rejected before any request, loader for what the request produced:
import { craftException, query } from '@craft-ng/core';
const { userQuery } =
yield *
query('userQuery', {
method: (value: string) =>
value.length < 3
? craftException(
{ code: 'SEARCH_TERM_TOO_SHORT' },
{ min: 3, received: value.length },
)
: value,
loader: async ({ params }) =>
params === 'forbidden'
? craftException({ code: 'USER_ACCESS_FORBIDDEN' }, { id: params })
: { id: params, name: 'John Doe' },
});
yield * userQuery.call('ab');
userQuery.hasException(); // true
userQuery.exceptions().params?.SEARCH_TERM_TOO_SHORT;
yield * userQuery.call('forbidden');
userQuery.exceptions().loader?.USER_ACCESS_FORBIDDEN;Returning a craftException from method means the loader never runs — you don't send a request you already know will fail.
Pitfalls
No value is available yet. Check hasValue() or handle the undefined result while the query is loading or in exception.
params must be cheap and pure. It runs inside a reactive computation; side effects belong in the loader.
Advanced — parallel queries by identifier
identifier keeps one resource per key, so several runs coexist instead of replacing each other:
const userId = signal<number | undefined>(undefined);
const { userQuery } =
yield *
query('userQuery', {
params: userId,
identifier: (id) => id,
loader: function* ({ params }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/users/${params}`,
success: response<User>(),
}));
},
});
userId.set(1);
userId.set(2);
userQuery.select('1').value(); // user 1
userQuery.select('2').value(); // user 2Advanced — typed HTTP exceptions
Loader exceptions are matched declaratively: each matcher yields predicates on the response and returns a craftException when it recognises the failure.
loader: function* ({ params }) {
return yield* CraftHttpClient.get(({ response }) => ({
url: `/api/users/${params}`,
success: response<User>(),
exceptions: [
function* ({ status, code, content }) {
if (!(yield* status(400))) return;
if (!(yield* code('PASSWORD_REQUIRED'))) return;
if (!(yield* content('Password is required'))) return;
return craftException({
code: 'PASSWORD_REQUIRED',
scope: 'UsersFeatureForDependencies',
});
},
function* ({ body, header }) {
const payload = yield* body<{
errors?: Array<{ field: 'password' }>;
}>();
if (!payload.errors?.some((error) => error.field === 'password')) return;
if (!(yield* header('x-error-kind', 'validation'))) return;
return craftException({
code: 'VALIDATION_HEADER_ERROR',
scope: 'UsersFeatureForDependencies',
});
},
],
}));
}Working source: exceptions demo.
Advanced — yielding dependencies from params
params can be a generator, and so can an insertion:
const { userQuery } =
yield *
query(
'userQuery',
{
providers: [provideUserService(), provideUserApiService()],
params: function* () {
return yield* UserService.userId();
},
loader: function* ({ params: userId }) {
return yield* UserApiService.get(userId);
},
},
function* () {
const queryTools = yield* QueryTools();
return { queryKey: `${queryTools.prefix()}:details` };
},
);See Also
- Mutations — the write side
- Reacting to mutations
- Anatomy of a primitive