Query
Build replayable query modules with @logixjs/query, and optionally plug in cache/dedup engines.
@logixjs/query turns “query params → resource loading → result snapshots” into a regular module. params / ui / result snapshots all live in module state, so they are subscribable, debuggable, and replayable.
0) Mental model (≤ 5 keywords)
single entry: all query capabilities come from@logixjs/query.same-shaped API:Query.make(...)plus controller handle extensions (isomorphic to how@logixjs/formis used).explicit injection: external engines are injected viaQuery.Engine.layer(...); enablingQuery.Engine.middleware()without injection fails explicitly (no silent fallback).replaceable engine: TanStack is the recommended default, but the engine is a swappable contract (Engine).replayable diagnostics: query pipelines emit slim, serializable evidence for explanation and replay.
0.1 Cost model (coarse-grained)
- Every refresh goes through
key(state) -> keyHashgating. Ifkeyisundefined, refresh is skipped (no-op). - Auto-trigger frequency is bounded by
deps+debounceMs: the largerdebounceMs, the more high-frequency input is converged into fewer real refreshes. concurrencydefines race semantics:switchinterrupts old in-flight work (and drops old results via thekeyHashgate);exhaust/trailing coalesces intermediate changes into one trailing run (reducing meaningless write-backs).- With external engine + middleware enabled: cache hits can avoid repeated
ResourceSpec.load. Budget: diagnostics=off adds p95 ≤ +1%, diagnostics=full/light adds p95 ≤ +5%.
0.2 Diagnostic fields (explainable chain)
When diagnostics are enabled, snapshots/events carry at least these fields to answer “why did it trigger / why did it write back”:
resourceId: resource identifier (fromResourceSpec.id)keyHash: stable key hash (computed from key)concurrency: concurrency strategy (e.g.switch,exhaust-trailing)status:idle/loading/success/error
1) Minimal usage: define a Query module
import { Schema } from 'effect'
import * as Logix from '@logixjs/core'
import * as Query from '@logixjs/query'
export const SearchSpec = Logix.Resource.make({
id: 'demo/user/search',
keySchema: Schema.Struct({ q: Schema.String }),
load: ({ q }) => /* Effect.Effect<...> */,
})
export const SearchQuery = Query.make('SearchQuery', {
params: Schema.Struct({ q: Schema.String }),
initialParams: { q: '' },
// ui: interaction-state namespace (toggles/panels/etc.); participates in deps/key
ui: { query: { autoEnabled: true } },
queries: {
list: {
resource: SearchSpec,
deps: ['params.q', 'ui.query.autoEnabled'],
triggers: ['onMount', 'onValueChange'],
concurrency: 'switch',
key: (state) => (state.ui.query.autoEnabled && state.params.q ? { q: state.params.q } : undefined),
},
},
})Key points:
paramsare business parameters;uiis interaction state (no preset shape, but should stay serializable/replayable).- Each query result is written back to
state.queries[queryName](ResourceSnapshot:idle/loading/success/error+keyHash). - TanStack v5’s
status:"pending"+fetchStatussemantics are not copied into the snapshot. Logix usesResourceSnapshot.statusas a 4-state model; “disabled/manual/params-not-ready” are expressed byparams/ui(andkey(state)returningundefined). depsmust be explicit: they are both the convergence basis and part of the explainability chain.
2) Compose Query as a normal submodule (recommended)
Query modules can be imported via imports like any other module. In React, prefer resolving the child runtime within the parent instance scope to avoid instance mismatches:
3) External engine (cache/dedup) + middleware (handoff point)
If you want “cache / in-flight dedup / invalidation / optional fast-read (reduce loading flicker)” to be handled by an external engine:
import * as Logix from '@logixjs/core'
import { Layer } from 'effect'
import * as Query from '@logixjs/query'
import { QueryClient } from '@tanstack/query-core'
export const runtime = Logix.Runtime.make(RootImpl, {
layer: Layer.mergeAll(AppInfraLayer, Query.Engine.layer(Query.TanStack.engine(new QueryClient()))),
middleware: [Query.Engine.middleware()],
})Composition semantics (“engine injection × middleware”):
- no injection + no middleware: run
ResourceSpec.loaddirectly (no cache/dedup) - injection only: no fetch takeover (usually not recommended as a default)
- middleware only: config error with a hint to inject (avoid silent fallback)
- both enabled: cache/dedup enabled (recommended; TanStack is the default adapter)
3.1 Invalidation and tags (optional)
invalidate({ kind: "byResource", resourceId })/invalidate({ kind: "byParams", resourceId, keyHash }): precise invalidation.invalidate({ kind: "byTag", tag }): tag-based invalidation. To avoid degrading into “refresh everything”, declare statictagsin your query config:
queries: {
list: {
// ...
tags: ['user'],
},
}3.2 Races and cancellation (switch / AbortSignal)
StateTrait.sourcedefaults toswitch: a new key interrupts old in-flight fibers. Even if cancellation doesn’t reach the network layer, old results are dropped by thekeyHashgate and won’t overwrite newer results.- If you want “real network cancellation” (e.g. axios), use Effect’s
AbortSignalinResourceSpec.load(e.g.Effect.tryPromise({ try: (signal) => axios.get(url, { signal }), catch: ... })). - See: Interruptible I/O (cancellation and timeout)
[!TIP]
StateTrait.sourceis part of the traits system. If you’re new to “capability rules / convergence / transaction windows”, start here:
3.3 Optimization ladder (simple → advanced)
Add capabilities gradually instead of pulling in all complexity at once:
- Pure pass-through (simplest): write
Query.make(...)only; no engine injection, no middleware — every refresh runsResourceSpec.loaddirectly. - Cache/dedup (recommended default): inject
Query.Engine.layer(Query.TanStack.engine(new QueryClient()))and enableQuery.Engine.middleware()— you get caching, in-flight dedup, and invalidation. - Reduce meaningless refresh: express “params not ready / disabled” via
key(state) => undefined, and ensuredepsincludes only fields that truly affect the key. - Avoid loading flicker (fast-read on cache hit): if the engine provides
peekFresh, Query tries to hit fresh cache before refresh and writes asuccesssnapshot directly. - Real cancel/timeout/retry: in
ResourceSpec.load, useAbortSignal+Effect.timeoutFail/Effect.retry, soswitchcan both drop old results and actually cancel network I/O.
3.4 Cache cap for long-running processes (TanStack engine)
If your key space may grow without bound (e.g. a long-running search input), set an upper bound for TanStack engine’s local fast cache:
Query.TanStack.engine(queryClient, { maxEntriesPerResource: 2000 })4) Trigger refetch from other modules (two ways, both needed)
4.1 Recommended: the “owner module” drives refresh for an imported child (best scope semantics)
When BModule imports an AQuery, the most robust approach is to keep linkage logic inside BModule’s Logic (B as owner). Resolve the imported AQuery handle within B’s instance scope, then trigger refresh explicitly.
import { Effect } from 'effect'
export const BLogic = BModule.logic(($) =>
Effect.gen(function* () {
const q = yield* $.use(AQuery)
// state change in B -> update AQuery params (let auto-trigger work)
yield* $.onState((s) => s.filters.keyword).runFork((keyword) => q.controller.setParams({ q: keyword }))
// or: force refetch even if params didn't change
yield* $.onState((s) => s.filters.forceReloadToken).runFork(() => q.controller.refresh())
}),
)Rules of thumb:
- “the importer owns the driving logic”: avoid Link/global listeners that manipulate child modules directly; multi-instance scenarios can refresh the wrong target.
- Prefer
setParams/setUiwhen possible (explainable and aligned with deps/keyHash); userefreshonly when you truly need a forced fetch.
4.2 When the trigger comes from another module: Link forwards signals, owner still refreshes
If the trigger source is CModule (not inside B’s own state), use Link.make to forward the signal into B.actions.*, and let BLogic refresh AQuery within B’s own scope—keeping encapsulation and instance semantics clear.
4.3 Advanced: collapse Query snapshot fields into a “host module” state (manual wiring)
If you strongly require “all state must live in one host module” (or you need to build a graph where multiple query snapshot fields are isomorphic to business state), you can use Query.traits(...) to generate StateTraitSpec and collapse query snapshot fields into state.queries.*.
However, triggering/invalidation/controller wiring must be organized explicitly by you, so only do this when necessary.