Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources

i.Learn

  • Overview
  • 1. Your first state
  • 2. Derive instead of duplicate
  • 3. Move logic out of the component
  • 4. Compose services
  • 5. Load server data
  • 6. Write server data
  • 7. Put state in the URL
  • 8. Build a form
  • 9. Wire up routing
  • 10. Test what you wrote
  • Where to go next
  1. Learn
  2. 5. Load server data

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#

ts
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:

typescript
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:

typescript
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:

typescript
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:

ts
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:

typescript
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:

typescript
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.

← 4. Compose services

6. Write server data →

←4. Compose services6. Write server data→

On this page

  • The query primitive
  • In the template
  • Triggering it yourself
  • Adding derived values
  • About the flicker
  • What you gained

@craft-ts · MIT licensed

GitHubnpm

Search