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
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Managing state
  2. queryParams

queryParams#

queryParams is a state whose home is the URL's query string. Reading and writing look like any other state; the address bar follows, and so does the back button.

Use it when the value should survive a refresh and be shareable by copying the link: filters, pagination, a selected tab. Not when the value is ephemeral or private — that's state.

No synchronisation code

There is no effect to write and no ActivatedRoute subscription. If you find yourself syncing a state with the URL, you want this primitive instead.

The common case#

typescript
import { queryParams } from '@craft-ts/core';

const numberCodec = {
  decode: (value: string) => parseInt(value, 10),
  encode: (value: number) => String(value),
};
const booleanCodec = {
  decode: (value: string) => value === 'true',
  encode: (value: boolean) => String(value),
};

const pagination =
  yield *
  queryParams(
    'pagination',
    {
      state: {
        page: { fallbackValue: 1, codec: numberCodec },
        showArchived: { fallbackValue: false, codec: booleanCodec },
      },
    },
    ({ set, update, patch, reset }) => ({ set, update, patch, reset }),
  );

pagination(); // { page: 1, showArchived: false }
pagination.page(); // 1

pagination.patch({ showArchived: true }); // navigates to ?showArchived=true
pagination.set({ page: 4, showArchived: false });
pagination.update((current) => ({ ...current, page: current.page + 1 }));
pagination.reset();

?page=3&showArchived=true becomes { page: 3, showArchived: true } on load.

Codecs are mandatory#

A URL only holds strings, so every parameter declares how it converts both ways. The decoded type is your application type; the encoded one is what appears in the address bar.

fallbackValue is what you get when the parameter is absent — which is why the state type is never undefined.

Codecs stay synchronous because they run inside the reactive URL computation. @craft-ts/core deliberately doesn't depend on a validation library: supply a small { decode, encode } pair directly, or adapt one from the library you already use.

typescript
// arrays
tags: {
  fallbackValue: [],
  codec: {
    decode: (value) => value.split(',').filter(Boolean),
    encode: (value) => value.join(','),
  },
},

// plain strings
q: { fallbackValue: '', codec: { decode: String, encode: String } },

The same pattern covers dates, enums and JSON-encoded objects.

Effect Schema adapter#

When the application already uses Effect Schema, keep queryParams as the URL owner and adapt the schema's synchronous entry points. This keeps URL parsing typed without introducing a second URL primitive:

typescript
import { Schema } from 'effect';

const Search = Schema.String;
const searchCodec = {
  decode: Schema.decodeUnknownSync(Search),
  encode: Schema.encodeSync(Search),
};

const filters =
  yield *
  queryParams('filters', {
    state: {
      search: { fallbackValue: '', codec: searchCodec },
    },
  });

Use the same adapter shape for numbers, booleans, dates, enums and arrays. A transformation schema is useful when the URL representation differs from the value used by the component; its decode and encode functions must still be synchronous. Missing values use fallbackValue, malformed values keep that fallback and expose QueryParamDecodeError, and reset() removes the keys from the URL. This covers the common filter, sort and pagination cases while preserving stable serialization in one place.

There is deliberately no queryParamsEffect: URL synchronisation belongs to Craft, while an Effect query or loader reacts to the resulting typed state.

Custom methods#

typescript
yield *
  queryParams(
    'pagination',
    {
      state: { page: { fallbackValue: 1, codec: numberCodec } },
    },
    ({ state, patch }) => ({
      nextPage: function* () {
        const current = yield* state();
        return yield* patch({ page: current.page + 1 });
      },
      previousPage: function* () {
        const current = yield* state();
        return yield* patch({ page: current.page - 1 });
      },
      setPageSize: function* (pageSize: number) {
        return yield* patch({ pageSize, page: 1 });
      },
    }),
  );

Feeding a query#

The point of URL state is usually to drive a fetch. Read it from the query's params:

typescript
yield* query('tasksQuery', {
  params: () => ({ page: pagination.page() }),
  loader: /* … */,
});

One direction of data flow: click → URL → loader → view.

Decode failures#

A decode that throws keeps the fallback value rather than corrupting your state, and surfaces the failure:

typescript
if (mode.hasException()) {
  mode.exceptions().list;
  mode.exceptions().parse.mode?.code; // 'QueryParamDecodeError'
  mode.exceptions().parse.mode?.payload;
}

An encode failure raises QueryParamEncodeError before router navigation starts.

Pitfalls#

Every parameter needs a codec — there is no implicit string passthrough.

Methods bound to a source with on$ are not exposed on the result, same as every primitive.

Advanced — declaring query params on the route

Query parameters can live in the route rather than in a component, so they belong to the URL definition itself:

typescript
export const { demoRoutes, injectDemoQueryParamsQueryParams } = craftRoutes(
  'demo',
  [
    {
      path: 'query-params',
      ...loadCraftComponent(({ withRetry }) =>
        withRetry(import('./qp-list-with-pagination')).then(
          ({ default: component }) => component,
        ),
      ),
      queryParams: function* () {
        const pagination = yield* queryParams(
          'pagination',
          {
            state: {
              page: { fallbackValue: 1, codec: numberCodec },
              pageSize: { fallbackValue: 4, codec: numberCodec },
            },
          },
          ({ patch, state }) => ({
            nextPage: function* () {
              const current = yield* state();
              return yield* patch({ page: current.page + 1 });
            },
            previousPage: function* () {
              const current = yield* state();
              return yield* patch({ page: current.page - 1 });
            },
            updatePageSize: function* (pageSize: number) {
              return yield* patch({ pageSize, page: 1 });
            },
          }),
        );
        return pagination;
      },
    },
  ],
);

Working source: exception-query-params.ts.

Advanced — yielding dependencies

The insertion can be a generator, so a rule can come from a service:

typescript
yield *
  queryParams(
    'pagination',
    { state: { page: { fallbackValue: 1, codec: numberCodec } } },
    function* ({ patch, state }) {
      return {
        nextPage: function* () {
          const current = yield* state();
          // A reactive member is read when it is yielded: this is the
          // current maximum, not the one from when the query params were built.
          if (current.page >= (yield* PaginationRules.maxPage())) return;
          return yield* patch(({ page }) => ({ page: page + 1 }));
        },
      };
    },
  );

Advanced — injectable writes

Insertion methods provide injectQueryParamsMethodRuntimeContext(), and the URL state itself is published to providePrimitiveResourceRuntimeObserver. Both expose get, set, update, and patch for wrappers, WebMCP tools, and other advanced patterns. See Anatomy of a primitive.

See Also#

  • Local state — for non-URL state

  • query — consuming URL state from a loader

  • Anatomy of a primitive

←MutationsasyncProcess→

On this page

  • The common case
  • Codecs are mandatory
  • Effect Schema adapter
  • Custom methods
  • Feeding a query
  • Decode failures
  • Pitfalls
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search