On this pageTranslating Concepts
Coming from TanStack Query
TanStack Query is excellent at what it does. If you are coming from it, you are used to caching, background refetching, deduplication, and retries arriving as configuration on a hook. Foldkit has no useQuery, and it does not need one. This page shows where that behavior lives instead.
The short version: caching, polling, deduplication, and stale-response handling are not features in Foldkit. They are behavior, expressed with the same primitives you already use for everything else: Model state, the update function, Commands, and Subscriptions. Each one is those pieces applied to a different situation, not a separate API you reach for. Every one of these behaviors stays right there in your code, readable and traceable, with nothing happening inside a machine you cannot see.
TanStack Query packages these behaviors as configuration on a hook (staleTime, refetchOnWindowFocus, and the rest), so a lot happens that you did not write and cannot see. Foldkit asks you to express the same behavior as state and transitions you own. The trade is not more code or less, it is hidden machinery versus code you can read.
Here is how the TanStack Query model maps onto Foldkit:
| TanStack Query | Foldkit |
|---|---|
useQuery | A Command plus an async-state field in the Model |
| Query cache (keyed by query key) | Model state: one field, or a HashMap keyed by id |
staleTime / background refetch | A Subscription gated on a Model condition |
| Request deduplication | Collapse it in update (it is just state) |
| Out-of-order response handling | A request id in the Model, checked in update |
invalidateQueries | Mark the field stale and return a refetch Command |
useMutation | A Message and a Command, the same as any other effect |
| Retries | Effect’s retry / Schedule |
| TanStack Query Devtools | Foldkit DevTools: inspect the Model and step through every Message |
A query has states: loading, success, error, and often a “refreshing with stale data on screen” state. In Foldkit you model those explicitly as a tagged union and store it in the Model. There is no separate cache. The Model is the cache. A single resource lives in one field; a collection of resources keyed by id lives in a HashMap. Reading from cache is reading the Model, and rendering instantly from cache is just rendering the data you already hold.
The API Cache example builds the core of what TanStack Query gives you (cache-and-revalidate, background polling, instant cache hits) out of nothing but a Model, an update function, Commands, and one Subscription. It is worth reading top to bottom.
Here is a bug that is easy to hit. Fire a request for A, then fire a request for B before A returns. A is slow, B is fast, so B resolves first, then A resolves last and overwrites it. Now you are showing A’s data when the user asked for B.
Foldkit does not auto-cancel in-flight effects, and there is no ordering guarantee between independent requests, so this is reachable. You handle it the same way TanStack Query does internally: track the latest request and ignore any response that is not it. Keep a request id in the Model, thread it through the Command into the result Message, and in update discard any result whose id is no longer current:
import { Effect, Match as M, Schema as S } from 'effect'
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from 'effect/unstable/http'
import { Command } from 'foldkit'
import { m } from 'foldkit/message'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
const Result = S.Struct({ id: S.String, title: S.String })
const ResultsLoading = ts('ResultsLoading')
const ResultsOk = ts('ResultsOk', { results: S.Array(Result) })
const ResultsFailure = ts('ResultsFailure', { error: S.String })
const Results = S.Union([ResultsLoading, ResultsOk, ResultsFailure])
// MODEL
const Model = S.Struct({
queryInput: S.String,
results: Results,
latestRequestId: S.Number,
})
type Model = typeof Model.Type
// MESSAGE
const ChangedQuery = m('ChangedQuery', { query: S.String })
const SucceededSearch = m('SucceededSearch', {
requestId: S.Number,
results: S.Array(Result),
})
const FailedSearch = m('FailedSearch', {
requestId: S.Number,
error: S.String,
})
const Message = S.Union([ChangedQuery, SucceededSearch, FailedSearch])
type Message = typeof Message.Type
// COMMAND
const Search = Command.define(
'Search',
{ requestId: S.Number, query: S.String },
SucceededSearch,
FailedSearch,
)(({ requestId, query }) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const request = HttpClientRequest.get('/api/search').pipe(
HttpClientRequest.setUrlParams({ q: query }),
)
const response = yield* client.execute(request)
const results = yield* S.decodeUnknownEffect(S.Array(Result))(
yield* response.json,
)
return SucceededSearch({ requestId, results })
}).pipe(
Effect.catch(error =>
Effect.succeed(FailedSearch({ requestId, error: String(error) })),
),
Effect.provide(FetchHttpClient.layer),
),
)
// UPDATE
const update = (
model: Model,
message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<
readonly [Model, ReadonlyArray<Command.Command<Message>>]
>(),
M.tagsExhaustive({
ChangedQuery: ({ query }) => {
const requestId = model.latestRequestId + 1
return [
evo(model, {
queryInput: () => query,
results: () => ResultsLoading(),
latestRequestId: () => requestId,
}),
[Search({ requestId, query })],
]
},
SucceededSearch: ({ requestId, results }) => {
if (requestId !== model.latestRequestId) {
return [model, []]
}
return [evo(model, { results: () => ResultsOk({ results }) }), []]
},
FailedSearch: ({ requestId, error }) => {
if (requestId !== model.latestRequestId) {
return [model, []]
}
return [evo(model, { results: () => ResultsFailure({ error }) }), []]
},
}),
)The late response for an earlier query sees that requestId no longer matches latestRequestId and is dropped. The newest query stays on screen.
This is the solution, not a workaround
It is tempting to read the request id as boilerplate you tolerate until something better comes along. It is not. The behavior you want, newest request wins, has to live somewhere. Here it lives in the Model as a value you can read, and in update as a comparison you can test. That visibility is the point, not a tax on it. The shape generalizes: tag each async result with what the Model wanted when you started it, then ignore any result that no longer matches. The same few lines that resolve this fetch race also resolve a debounced search box firing on every keystroke, because the question is identical: is this result still the one the Model is waiting for?
Where is useQuery?
There isn’t one, and you don’t assemble an equivalent hook. You return a Command from update, the runtime runs the effect and feeds the result back as a Message, and you store the resulting state in the Model. The “query” is spread across those pieces on purpose, so each one stays visible.
How do I cache responses?
Keep the data in the Model. For a single resource that is one field; for many resources, a HashMap keyed by id. A cache hit is finding the data already in the Model and rendering it without firing a Command. See the API Cache example.
How do I deduplicate identical requests?
In update, check the current state before firing. If the field is already Loading, return no Command. Because every request is a decision made in one place, deduplication is just an if, not a feature. Note this is a different thing from out-of-order handling above: dedup avoids starting redundant work, the request-id guard resolves work that finishes out of order.
How do I poll or refetch in the background?
Use a Subscription gated on a Model condition. It starts the interval when the condition becomes true and tears it down when it becomes false, with no manual cleanup. The API Cache example refetches stats on a timer this way while keeping the old numbers on screen.
How do I invalidate and refetch?
Set the field to a stale or refreshing state and return the fetch Command from update. The current data can stay on screen while the new request runs, which is the same cache-and-revalidate behavior you are used to, expressed as an explicit state transition.
What about mutations?
A mutation is a Message and a Command, the same as any other effect. The button dispatches a Message, update returns a Command that performs the write, and the result comes back as another Message you handle. There is no separate mutation concept to learn.
Why write this yourself instead of letting a library do it?
Because the logic that resolves a race or revalidates a cache is logic your app depends on, and keeping it as state and transitions you own means you can read it, test it, and change it. A hook that does it for you owns that logic instead, and the day your case diverges from its defaults you are reaching for configuration and hoping the knob you need exists. Owning the behavior is not the cost of this approach. It is the point of it.
If you are also coming from React, Coming from React covers the rest of the mental model: components, hooks, effects, and how they map onto Foldkit.