Data fetching
A react-x11 app is an ordinary Node process, so fetch, undici, ky and
graphql-request work unmodified. The cache layer on top needs care:
stale-while-revalidate libraries decide whether they are "on a server" by
looking for a global window, and a react-x11 process has none. Get it
wrong and nothing throws; the app just stops refetching.
TanStack Query
Adapter, ~12 lines. @tanstack/react-query@5.101.4 (query-core 5.101.4).
useQuery deduplicates, caches, retries and background-refetches;
useMutation handles writes with invalidation. The environment coupling
lives in three replaceable managers — focusManager, onlineManager,
environmentManager. The trap is query-core's probe:
// @tanstack/query-core src/utils.ts
export const isServer = typeof window === 'undefined' || 'Deno' in globalThis;
With no window, query-core decides it is doing SSR:
retrydefaults to 0 instead of 3;- stale timers are never scheduled and
refetchIntervalis disabled; gcTimedefaults to Infinity instead of 5 minutes — the cache never collects;- the default
focusManagersetup wantswindow.addEventListener('visibilitychange'), so focus refetching is inert.
The fix is environmentManager.setIsServer(), re-exported from
@tanstack/react-query, paired with react-x11's window focus events:
// query-x11.js — the whole adapter
import {
environmentManager,
focusManager,
onlineManager,
} from '@tanstack/react-query';
environmentManager.setIsServer(() => false); // we are a client, not SSR
focusManager.setEventListener(() => () => {}); // drop the visibilitychange probe
onlineManager.setEventListener(() => () => {}); // assume online (or probe for real)
export const windowFocusProps = {
onFocus: () => focusManager.setFocused(true),
onBlur: () => focusManager.setFocused(false),
};
import React from 'react';
import { createRoot } from 'react-x11';
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query';
import { windowFocusProps } from './query-x11.js';
const client = new QueryClient();
function Weather() {
const { data, status } = useQuery({
queryKey: ['weather'],
queryFn: () => fetch('https://wttr.in/?format=3').then((r) => r.text()),
});
return <text>{status === 'pending' ? 'loading…' : data}</text>;
}
(await createRoot()).render(
<window width={360} height={120} title="weather" {...windowFocusProps}>
<QueryClientProvider client={client}>
<Weather />
</QueryClientProvider>
</window>,
);
Focusing the window now refetches stale queries, like refocusing a browser tab.
- Call
setIsServerbefore creating theQueryClient— the managers are module singletons. - Switching between two of your own windows fires blur-then-focus, which
refetches stale queries; raise
staleTimeif that is too eager. onlineManagerdefaults to online under Node (nonavigator.onLine). Wire it to NetworkManager over D-Bus for real offline handling.@tanstack/react-query-devtoolsis DOM-rendered and not usable.
SWR
Adapter, 1 line — but read the dead end first. swr@2.4.2.
useSWR fetches on mount, caches, dedupes and re-renders unmodified. Focus
and reconnect revalidation fail silently: SWR computes its environment once
at import time, with no override:
// swr/dist/_internal/config-context-*.mjs
const isWindowDefined = typeof window != 'undefined';
const IS_SERVER = !isWindowDefined || isLegacyDeno;
Revalidation event sources are registered only if (!IS_SERVER) —
including a custom initFocus/initReconnect passed through the
cache-provider config, so the provider approach from SWR's docs is dead code
under Node and swr registers zero listeners. There is no setIsServer.
Two ways out. The simple one: drive revalidation with mutate, whose key
filter () => true revalidates every cached key, from <window onFocus>:
import React from 'react';
import { createRoot } from 'react-x11';
import useSWR, { mutate } from 'swr';
const fetcher = (url) => fetch(url).then((r) => r.text());
function Weather() {
const { data } = useSWR('https://wttr.in/?format=3', fetcher);
return <text>{data ?? 'loading…'}</text>;
}
(await createRoot()).render(
// the adapter is this one prop: revalidate everything on window focus
<window
width={360}
height={120}
title="weather"
onFocus={() => mutate(() => true)}
>
<Weather />
</window>,
);
The thorough one: make IS_SERVER false, which turns swr's own machinery
back on. It has to happen before the first import of swr:
// swr-env.js — imported first, before anything that imports swr
const listeners = new Map();
const target = {
addEventListener: (t, fn) =>
listeners.set(t, [...(listeners.get(t) ?? []), fn]),
removeEventListener: (t, fn) =>
listeners.set(
t,
(listeners.get(t) ?? []).filter((f) => f !== fn),
),
};
globalThis.window ??= target;
globalThis.document ??= target;
/** Call from your <window onFocus> handler. */
export const fireFocus = () =>
(listeners.get('focus') ?? []).forEach((fn) => fn());
swr then registers visibilitychange, focus, online and offline
listeners and revalidateOnFocus behaves. Two gotchas:
- Fire only
'focus'. An'offline'event sets swr's internal online flag false and silently suppresses the deferred revalidation. focusThrottleIntervaldefaults to 5000 ms, so a focus within five seconds of the last revalidation is dropped — which looks exactly like the shim not working. Lower it while testing the wiring.
Other caveats:
revalidateOnFocusandrevalidateOnReconnectare inert unless you define the globals. Nothing warns you.- The global
mutatetargets the default cache, so with a customprovidervia<SWRConfig>it silently misses; useuseSWRConfig().mutatefrom inside the tree. - Suspense mode throws
Fallback data is required when using Suspense in SSR.because SWR believes it is on a server. ProvidefallbackData, or avoidsuspense: true. preload()is an SSR-gated no-op under Node.- For focus revalidation with retries and cache GC done properly, prefer TanStack Query — it ships a supported environment override.