Data loading and the query cache
The @netscript/fresh/query subpath gives islands a package-owned data layer
built on TanStack Query, while the package root exposes a small set of
cache-entry helpers that page loaders and islands share. Reach for this surface
when an island needs cached, refetchable, or live data, and when a server loader
wants to prime that cache before the island hydrates.
The two surfaces
Data loading in NetScript Fresh spans two import points:
@netscript/fresh/query— TanStack Query hooks for islands. Island code imports from this subpath rather than from@tanstack/preact-querydirectly, which centralizes the dependency and enables framework-level enhancements. It provides the island provider (QueryIsland), the sharedQueryClientsingleton (getIslandQueryClient), the query and mutation hooks (useIslandQuery,useIslandMutation, and friends), live-query hooks (useLiveQuery,useLiveSuspenseQuery), and hydration utilities for streaming SSR.@netscript/fresh(root) — the cross-cutting page-loader cache helpers. These operate on plainCacheEntryLikevalues: adatapayload plus acachedAtUnix-epoch timestamp in milliseconds. They let a loader reason about whether its cache is complete and how fresh it is before deciding to serve cached data or refetch.
Every other capability lives on its own explicit subpath (./builders,
./route, ./form, ./defer, ./server, ./streams, ./interactive,
./vite, ./error, and ./testing).
Cache entries at the loader
A CacheEntryLike<T> is the shared cache shape across page loaders and partial
orchestration:
interface CacheEntryLike<T> {
readonly data: T;
readonly cachedAt: number; // Unix epoch ms
}
The root helpers operate over arrays of these entries:
hasAllCacheEntries(entries)returnstruewhen every supplied entry is present.nullandundefinedentries count as missing, so a loader can pass the raw results of several cache reads and learn in one call whether the page can be served entirely from cache.minCachedAt(entries)returns the oldestcachedAttimestamp across the supplied entries, orundefinedwhen there are none. Use it to compute the effective freshness of a composed page — the page is only as fresh as its stalest entry.projectCachedItemFromList(listEntry, predicate)projects a single cached item out of aCachedListEntryLike<TItem>(an entry whosedataholds anitemsarray) while preserving the list'scachedAttimestamp. This lets a detail view reuse a list response already in cache instead of issuing a new fetch.
A cache-first load pattern
A loader can read its cache entries, check completeness, and derive freshness before deciding whether to refetch:
import {
hasAllCacheEntries,
minCachedAt,
projectCachedItemFromList,
type CacheEntryLike,
type CachedListEntryLike,
} from "@netscript/fresh";
interface Widget {
id: string;
name: string;
}
// Cache entries gathered earlier in the request.
const widgetList: CachedListEntryLike<Widget> | undefined = readWidgetList();
const summary: CacheEntryLike<number> | undefined = readSummary();
const entries = [widgetList, summary];
if (hasAllCacheEntries(entries)) {
const oldest = minCachedAt(entries); // freshness floor, in epoch ms
const selected = projectCachedItemFromList(
widgetList,
(item) => item.id === "w-1",
);
// Serve cached data along with its freshness floor.
return { widget: selected?.data, cachedAt: oldest };
}
// Otherwise, fall through to a fresh load.
Querying inside an island
Islands wrap their content in QueryIsland, the island-level TanStack Query
provider, then call the package-owned hooks. useIslandQuery runs a query
through the shared client; its options come from IslandQueryOptions:
| Option | Meaning |
|---|---|
queryKey |
Stable cache key (QueryKey, a readonly unknown[]). |
queryFn |
Function that loads query data. |
initialData |
Initial data supplied from a Fresh server loader. |
enabled |
Whether the query should run automatically. |
staleTime |
Cache freshness duration in milliseconds. |
gcTime |
Cache garbage-collection duration in milliseconds. |
select |
Optional projection applied by the query adapter. |
onError |
Optional error callback. |
refetchInterval |
Polling cadence in milliseconds (number | false). When set, the query refetches on this cadence; false disables polling. Defaults to false. |
refetchIntervalInBackground |
Whether polling continues while the tab or window is backgrounded (boolean). Only relevant when refetchInterval is set. Defaults to false. |
The hook returns an IslandQueryResult with data, error, status,
isLoading, isSuccess, isError, and a refetch() method.
From contract to island: the typed query chain
queryFn can be any function that returns data — but on a NetScript service it
should almost never be a hand-written fetch. A raw fetch("/api/widgets")
re-types the response by hand, hardcodes the path, and drifts silently the moment
the service changes. The @netscript/sdk bridge removes
that gap: derive the queryFn (and its queryKey) straight from the same oRPC
contract the service implements, so the island cannot drift from the API.
The chain has three links, and each service owns one module under lib/ — the
single source of typed clients every loader and island imports:
// apps/dashboard/lib/widgets.ts
import { createServiceClient } from "@netscript/sdk/client";
import { createQueryFactories } from "@netscript/sdk/query";
import { WidgetsContractV1 } from "@my-app/contracts";
// 1. A typed client per service. `serviceName` resolves a URL via Aspire
// discovery; the contract drives every method signature.
export const widgetsClient = createServiceClient<typeof WidgetsContractV1>({
contract: WidgetsContractV1,
serviceName: "widgets",
});
// 2. The cache-aware query factory exposes queryOptions(input),
// mutationOptions(), clientKey(input?), and the server KV helpers.
export const widgetsQueries = createQueryFactories({
widgets: { contract: WidgetsContractV1, client: widgetsClient },
}).widgets;
Islands then import from that module and spread the generated options into the
island hooks. queryOptions(input) supplies both the queryKey and a
queryFn bound to the contract, so the hook call only adds island concerns like
staleTime:
// apps/dashboard/islands/WidgetIsland.tsx
import { QueryIsland, useIslandQuery } from "@netscript/fresh/query";
import { widgetsQueries } from "../lib/widgets.ts";
function WidgetView() {
// queryKey + queryFn come from the contract; the input is typed, too.
const query = useIslandQuery({
...widgetsQueries.list.queryOptions({}),
staleTime: 30_000,
});
if (query.isLoading) return <p>Loading…</p>;
if (query.isError) return <p>Could not load widgets.</p>;
return (
<ul>
{query.data?.map((widget) => <li key={widget.id}>{widget.name}</li>)}
</ul>
);
}
export default function WidgetIsland() {
return (
<QueryIsland>
<WidgetView />
</QueryIsland>
);
}
widgetsQueries.list.queryOptions(...) returns a typed { queryKey, queryFn }; the
widget element is inferred from the contract's output, so a renamed field is a
compile error, not a runtime surprise. The same factory also supplies the KV-backed
server SWR and prefetch helpers described in the
Typed SDK & client pillar, so loaders and islands share one
input shape and one query-key vocabulary.
initialData is the bridge between a server loader and the island: pass the
loader's cached payload as initialData so the island renders with data
immediately and only refetches once staleTime elapses.
Polling an island with refetchInterval
When an island needs to poll for a changing status — a document moving from
pending to embedding to ready, say — set refetchInterval rather than
wiring a manual setInterval. The hook refetches on that cadence and cleans the
timer up on unmount. Add refetchIntervalInBackground: true when polling must
continue while the tab is backgrounded (the default stops polling on blur).
import { useIslandQuery } from "@netscript/fresh/query";
import { docsQueries } from "../lib/docs.ts";
function DocStatus({ id }: { id: string }) {
const query = useIslandQuery({
// Contract-derived queryKey + queryFn; `id` is the typed procedure input.
...docsQueries.getById.queryOptions({ id }),
// Poll every 2s; flip to false from state once the status reaches "ready".
refetchInterval: 2_000,
refetchIntervalInBackground: true,
});
return <span>{query.data?.status ?? "loading…"}</span>;
}
refetchInterval is typed number | false: pass a millisecond cadence to poll,
or false to disable it (for example, flip it to false from state once the
status reaches ready to stop polling).
Mutations, infinite queries, and live data
useIslandMutationruns a mutation through the shared client. Its options (IslandMutationOptions) take amutationFnplus optionalonMutate,onSuccess,onError, andonSettledcallbacks; the result exposesmutate,mutateAsync,status,isPending,data, anderror.onMutateruns before the mutation fires and can return context (for example, a snapshot of prior data) thatonSuccess,onError, andonSettledreceive back — the seam for optimistic updates with rollback.onSettledruns after either a success or an error.useIslandInfiniteQueryloads paged data. Its options extend the query options withinitialPageParamandgetNextPageParam; the result addshasNextPage,isFetchingNextPage, andfetchNextPage().useIslandSuspenseQueryanduseIslandSuspenseInfiniteQueryare the Suspense variants; the suspense query result guaranteesdatais present for rendered consumers.useLiveQueryanduseLiveSuspenseQueryaccept anIslandLiveQueryFactoryand an optional dependency array, returning anIslandLiveQueryResultwithdata,status,error, and adetailsrecord of preserved upstream fields.
Shorter aliases — useQuery, useMutation, useInfiniteQuery,
useSuspenseQuery, and useSuspenseInfiniteQuery — map to their useIsland*
counterparts for backward compatibility.
A worked mutation: optimistic toggle with rollback
This island toggles a todo's done flag through useIslandMutation, applying the
change optimistically and rolling back if the server rejects it. onMutate snapshots
the cached list and returns it as context, onError restores that snapshot, and
onSettled re-syncs with the server:
// apps/dashboard/islands/TodoIsland.tsx
import {
QueryIsland,
useIslandMutation,
useQueryClient,
} from "@netscript/fresh/query";
import { todosQueries } from "../lib/todos.ts";
interface Todo {
id: string;
title: string;
done: boolean;
}
function TodoToggle({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const listKey = todosQueries.list.clientKey({});
const toggle = useIslandMutation({
// 1. The round-trip: mutationOptions() supplies the contract-bound
// mutationFn and mutationKey; `mutate` takes the typed procedure input.
...todosQueries.update.mutationOptions(),
// 2. Optimistic update: snapshot prior state, write the expected next state.
onMutate: async (variables: { id: string; done: boolean }) => {
await queryClient.cancelQueries({ queryKey: listKey });
const previous = queryClient.getQueryData<Todo[]>(listKey);
queryClient.setQueryData<Todo[]>(
listKey,
(list) =>
(list ?? []).map((item) =>
item.id === variables.id ? { ...item, done: variables.done } : item
),
);
return { previous };
},
// 3. Roll back to the snapshot when the server rejects the change.
onError: (_error, _variables, context) => {
const snapshot = context as { previous?: Todo[] } | undefined;
if (snapshot?.previous) queryClient.setQueryData(listKey, snapshot.previous);
},
// 4. Re-sync with the server once the mutation settles, either way.
onSettled: () => queryClient.invalidateQueries({ queryKey: listKey }),
});
return (
<button
type="button"
disabled={toggle.isPending}
onClick={() => toggle.mutate({ id: todo.id, done: !todo.done })}
>
{todo.done ? "Mark undone" : "Mark done"}
</button>
);
}
export default function TodoIsland({ todo }: { todo: Todo }) {
return (
<QueryIsland>
<TodoToggle todo={todo} />
</QueryIsland>
);
}
Spreading todosQueries.update.mutationOptions() supplies the contract-bound
mutationFn and mutationKey; the optimistic seam — onMutate, onError,
onSettled — is added on the island hook, exactly where the query cache lives.
mutate takes the typed procedure input ({ id, done } here), and the
todosQueries.list.clientKey(input) builds the same key the list query uses,
so the snapshot, rollback, and invalidation all target the right cache entry.
Call mutate for fire-and-forget UI updates, or mutateAsync when you need to
await the result.
Endpoints without a NetScript contract
Sometimes an island calls something that has no oRPC contract — a third-party
REST API, a legacy endpoint, or a webhook receiver. That is the one case where a
hand-written queryFn is correct: there is no contract to derive from, so you own
the request and its type. Keep it explicit and isolated so it does not read as the
default pattern:
import { useIslandQuery } from "@netscript/fresh/query";
interface Rate {
base: string;
value: number;
}
// No NetScript contract exists for this external endpoint, so the fetch and its
// response type are written by hand — the deliberate exception, not the norm.
function ExchangeRate() {
const query = useIslandQuery<Rate>({
queryKey: ["exchange-rate", "USD"],
queryFn: () =>
fetch("https://api.example.com/rates/USD").then(
(res) => res.json() as Promise<Rate>,
),
staleTime: 60_000,
});
return <span>{query.data ? `1 ${query.data.base} = ${query.data.value}` : "…"}</span>;
}
For any endpoint that does have a NetScript contract, reach for the typed chain
above instead — the raw fetch here exists only because there is nothing typed to
bind to.
Server prefetch and hydration
For streaming SSR, prefetch on the server and hand the cache to the island:
- Prefetch into a per-request
QueryClient, then calldehydrateQueryClient(queryClient)to produce a serializableDehydratedState(itsqueriesandmutationsarrays). - Serialize that state into the document with
QueryHydrationScript, or pass it directly to an island. - On the client, restore it with
HydrationBoundary(which reads either astateprop or a script tag byid) or callhydrateFromDehydratedagainst the island client.
getIslandQueryClient() returns the shared singleton. useQueryClient returns
the active handle inside an island, and resetIslandQueryClient() clears the
singleton, primarily for tests.
The cache-first variant of this handoff — SDK query factories, the server and
client key tiers, getCachedEntry() at the loader, and when to dehydrate rather
than pass initialData — is The query bridge.
API summary
Root cache helpers (@netscript/fresh):
| Symbol | Description |
|---|---|
hasAllCacheEntries |
Return true when every supplied entry is present. |
minCachedAt |
Return the oldest cachedAt timestamp across the supplied entries. |
projectCachedItemFromList |
Project a single cached item from a cached list while preserving the timestamp. |
CacheEntryLike<T> |
Cached-entry shape: data payload plus cachedAt epoch-ms timestamp. |
CachedListEntryLike<TItem> |
Cached list-entry shape with a data.items array, used for projection. |
Query subpath (@netscript/fresh/query):
| Symbol | Description |
|---|---|
QueryIsland |
Island-level TanStack Query provider. |
useIslandQuery |
Run an island query through the shared QueryClient. |
useIslandMutation |
Run an island mutation through the shared QueryClient. |
useIslandInfiniteQuery |
Run an island infinite (paged) query. |
useIslandSuspenseQuery |
Suspense variant of the island query. |
useLiveQuery |
Run an island live query through the query surface. |
useLiveSuspenseQuery |
Suspense variant of the live query. |
getIslandQueryClient |
Get (or create) the shared island QueryClient. |
useQueryClient |
Return the active island QueryClient handle. |
resetIslandQueryClient |
Reset the island QueryClient singleton (testing). |
useIsFetching / useIsMutating |
Count active island queries / mutations. |
dehydrateQueryClient |
Dehydrate a QueryClient into serializable state. |
hydrateFromDehydrated |
Hydrate a client from server-dehydrated state. |
QueryHydrationScript |
Render a JSON script tag of dehydrated state. |
HydrationBoundary |
Hydrate the island client from state or a script tag. |
IslandQueryOptions |
Options for useIslandQuery. |
IslandQueryResult |
Result of an island query hook call. |
DehydratedState |
State produced by server-side query dehydration. |
QueryKey |
Stable query-key shape (readonly unknown[]). |
LoaderData |
Resolve the awaited return value of a Fresh route loader. |
Related
How server pages compose and load.
Routing and route contractsRoute definitions and loaders.
The query bridgeSDK factories, key tiers, and server-to-island hydration.
Typed SDK & clientThe contract → client → query-utils bridge queryFn is built on.
Interactive islandsWhere query hooks run on the client.
Deferred and streaming UIStream data into hydrating islands.
Server-validated formsPair mutations with form handling.
Live dashboard tutorialBuild a cache-first, live-updating page.
See also the Web Layer hub.