Utilities

Server State (@reckona/mreact-query)

@reckona/mreact-query provides server state and async cache primitives for Mreact. Use it when data belongs to a remote source, needs request dedupe, should hydrate from a server loader, or should be invalidated after a mutation.

Source: packages/query on GitHub.

Install

pnpm add @reckona/mreact-query

When to use it

Use @reckona/mreact-query for data fetched from HTTP APIs, databases behind loaders, paginated feeds, search results, mutations, and invalidation. It is server state: your app observes and caches it, but the source of truth lives elsewhere.

Use @reckona/mreact-store for application state that your app owns, such as open panels, local selections, and shared UI state.

Query clients and hydration

Create a query client, prefetch server data, dehydrate it, then hydrate the browser client so observers can reuse the result.

import {
  createQuery,
  createQueryClient,
  dehydrate,
  getQueryClient,
  hydrate,
} from "@reckona/mreact-query";

const queryClient = createQueryClient();

await queryClient.prefetchQuery({
  queryKey: ["profile"],
  retry: 2,
  retryDelay: 100,
  queryFn: ({ signal }) => fetch("/api/profile", { signal }).then((res) => res.json()),
});

const state = dehydrate(queryClient);
hydrate(getQueryClient(), state);

const profile = createQuery(getQueryClient(), {
  queryKey: ["profile"],
  queryFn: ({ signal }) => fetch("/api/profile", { signal }).then((res) => res.json()),
  staleTime: 30_000,
});

Set staleTime when browser observers should reuse server-fetched data during initial mount instead of immediately revalidating.

Router loaders

Inside Mreact Router, prefer the request-scoped query client from the route loader context when server data should also hydrate client-side observers.

// src/app/users/$id/page.tsx
import type { LoaderContext } from "@reckona/mreact-router";

export async function loader(context: LoaderContext<{ id: string }>) {
  return context.queryClient.fetchQuery({
    queryKey: ["user", context.params.id],
    staleTime: 30_000,
    queryFn: () => findUser(context.params.id),
  });
}

Each request gets its own query client. This dedupes repeated work during one render and avoids cross-request data leaks.

SSR first render with browser revalidation

A common production shape is SSR for the first view, then SWR-style browser revalidation after the page is interactive. Use the route loader to fill the per-request query cache, then create a browser observer with the same queryKey. The observer reads the hydrated cache first and can refetch when the tab becomes active, when the network reconnects, or when your own polling timer calls observer.refetch().

// src/lib/dashboard-summary.ts
export interface DashboardSummary {
  openIncidents: number;
  revenueToday: number;
  updatedAt: string;
}

export const DASHBOARD_SUMMARY_KEY = ["dashboard", "summary"] as const;
export const DASHBOARD_SUMMARY_STALE_TIME_MS = 30_000;
// src/app/dashboard/page.tsx
import type { LoaderContext } from "@reckona/mreact-router";
import {
  DASHBOARD_SUMMARY_KEY,
  DASHBOARD_SUMMARY_STALE_TIME_MS,
  type DashboardSummary,
} from "../../lib/dashboard-summary.js";
import { fetchDashboardSummary } from "../../lib/dashboard-summary.server.js";
import { DashboardLiveSummary } from "./summary.client.js";

export async function loader(context: LoaderContext): Promise<DashboardSummary> {
  return context.queryClient.fetchQuery({
    queryKey: DASHBOARD_SUMMARY_KEY,
    staleTime: DASHBOARD_SUMMARY_STALE_TIME_MS,
    queryFn: () => fetchDashboardSummary(),
  });
}

export default function Page(props: { data: DashboardSummary }) {
  return (
    <main>
      <h1>Dashboard</h1>
      <DashboardLiveSummary initialSummary={props.data} />
    </main>
  );
}
// src/app/dashboard/summary.client.tsx
import { createQuery, getQueryClient } from "@reckona/mreact-query";
import { useEffect } from "@reckona/mreact";
import {
  DASHBOARD_SUMMARY_KEY,
  DASHBOARD_SUMMARY_STALE_TIME_MS,
  type DashboardSummary,
} from "../../lib/dashboard-summary.js";

export function DashboardLiveSummary(props: { initialSummary: DashboardSummary }) {
  const observer = createQuery<DashboardSummary>(getQueryClient(), {
    queryKey: DASHBOARD_SUMMARY_KEY,
    staleTime: DASHBOARD_SUMMARY_STALE_TIME_MS,
    refetchOnWindowFocus: true,
    refetchOnReconnect: true,
    queryFn: ({ signal }) => fetch("/api/dashboard/summary", { signal }).then((res) => res.json()),
  });
  const summary = observer.result.get().data ?? props.initialSummary;

  useEffect(() => {
    const intervalId = window.setInterval(() => {
      void observer.refetch();
    }, 60_000);

    return () => {
      window.clearInterval(intervalId);
      observer.dispose();
    };
  }, []);

  return (
    <section>
      <p>Open incidents: {summary.openIncidents}</p>
      <p>Revenue today: {summary.revenueToday}</p>
      <p>Updated: {summary.updatedAt}</p>
      {observer.result.get().isFetching ? <p>Refreshing...</p> : null}
    </section>
  );
}

refetchOnWindowFocus listens for focus and visible-tab transitions. refetchOnReconnect listens for the browser online event. refetchOnInvalidate lets an active observer refetch when its query key is invalidated, which is useful when a mutation updates server state and the visible view should refresh without waiting for focus, reconnect, or a manual refetch(). Mreact Query does not currently have a built-in refetchInterval option, so use a small client-side timer when the view needs periodic polling. Always dispose long-lived observers and timers when integrating outside a component cleanup lifecycle.

Cross-tab sync

Use syncQueryClientAcrossTabs() when same-origin browser tabs should observe each other's invalidations or avoid duplicate focus/reconnect refetches. The adapter uses BroadcastChannel for cache messages and Web Locks for singleFlight leader selection when the browser supports it.

import { getQueryClient, syncQueryClientAcrossTabs } from "@reckona/mreact-query";

const client = getQueryClient();

syncQueryClientAcrossTabs(client, {
  channel: `mreact-query:v1:user:${sessionId}`,
  includeQuery: (queryKey) => queryKey[0] === "dashboard",
  singleFlight: true,
});

The adapter only sends or receives query messages when channel is a non-default name scoped to the current authenticated user, tenant, or equivalent namespace and includeQuery explicitly allows the query key. Channel names are not secrets or authorization boundaries: any trusted or untrusted script running in the same origin can open the same BroadcastChannel, observe shared successful data, or send messages for allowed keys. Use data sharing only when the whole same-origin runtime is trusted, and do not share tokens, authorization-bearing data, sensitive PII, or query keys that reveal sensitive information. Invalidations and removals with a query key are broadcast inside that namespace by default, while keyless invalidations and removals stay local. Successful query data is not broadcast unless you set broadcastQueryData: true or enable singleFlight in a browser with Web Locks, because singleFlight hands the leader's successful data to follower tabs. Use includeQuery to keep sensitive or unrelated query keys out of the channel.

With singleFlight: true, focus and reconnect revalidation still uses normal createQuery() options such as refetchOnWindowFocus and refetchOnReconnect; the cross-tab adapter wraps fetchQuery() so same-key browser refetches can share one in-flight request. Without Web Locks or the required scope options, singleFlight falls back to local fetch behavior without leader election or implicit result handoff. broadcastQueryData: true is separate from Web Locks and still shares successful data when the scoped channel and allowlist are configured.

Infinite queries

Use createInfiniteQuery() for cursor timelines and feeds that should not hand-roll page state or concurrent next-page dedupe.

const feed = createInfiniteQuery(getQueryClient(), {
  queryKey: ["timeline"],
  initialPageParam: null as string | null,
  queryFn: ({ pageParam, signal }) =>
    fetch(`/api/timeline?cursor=${pageParam ?? ""}`, { signal }).then((res) => res.json()),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});

await feed.fetchNextPage();
const items = feed.result.get().pages.flatMap((page) => page.items);

Mutations and invalidation

Use createMutation() when a client action changes server state. Keep query keys stable so successful mutations can invalidate or refetch the affected data.

const saveProfile = createMutation<{ name: string }, Profile>(getQueryClient(), {
  mutationFn: (values) =>
    fetch("/api/profile", {
      method: "POST",
      body: JSON.stringify(values),
    }).then((res) => res.json()),
  onSuccess: () => {
    getQueryClient().invalidateQueries({ queryKey: ["profile"] });
  },
});

For optimistic updates, return rollback context from onMutate; onError and onSettled receive that value so failed mutations can restore the previous cache state.

Use queryClient.setQueryData(queryKey, updater) when the new value can be derived from the previous cached value without fetching. The updater receives undefined when the query is not currently cached, so keep it total and side-effect free.

getQueryClient().setQueryData<Profile>(["profile"], (previous) => ({
  ...(previous ?? { name: "" }),
  name: "Ada Lovelace",
}));

Cancellation and cleanup

Query functions receive an AbortSignal. Pass it to fetch() so cancelQueries() can abort in-flight work.

await queryClient.cancelQueries({ queryKey: ["search"] });
queryClient.removeQueries({ queryKey: ["search"] });

Use gcTime on short-lived browser views when idle cache entries should be evicted after observers dispose.

API surface

  • createQueryClient()
  • getQueryClient()
  • runWithQueryClient()
  • installQueryAsyncStorage()
  • createQuery()
  • createInfiniteQuery()
  • createMutation()
  • dehydrate()
  • hydrate()
  • hashQueryKey()
  • syncQueryClientAcrossTabs()

API reference: these links open the generated TypeDoc pages inside this docs site.