Skip to content

Recipes

The API reference covers the primitives; these recipes cover the questions every adopter asks once the demo is on screen. Copy them, file off the parts that do not apply, ship.

The playground reads ?demo=spotlight and opens the palette on load. Same pattern works for any system window or app.

import { useEffect } from "react";
import { useWindowManager } from "@react-ui-os/core";
import { SPOTLIGHT_OPEN_EVENT } from "@react-ui-os/desktop";
function DemoActivator() {
const { openWindow } = useWindowManager();
useEffect(() => {
const demo = new URLSearchParams(location.search).get("demo");
if (!demo) return;
switch (demo) {
case "spotlight":
window.dispatchEvent(new CustomEvent(SPOTLIGHT_OPEN_EVENT));
break;
case "settings":
openWindow({ kind: "system", systemId: "settings" });
break;
case "notes":
openWindow({ kind: "app", appId: "notes" });
break;
}
}, [openWindow]);
return null;
}
<Desktop apps={apps} theme={theme}>
<DemoActivator />
</Desktop>;

The activator must be rendered as a child of <Desktop> so useWindowManager() can find the provider. See <Desktop children>.

Pass a custom StorageAdapter that writes to your backend instead of localStorage. The window manager treats it the same way. The interface is synchronous: get returns a value now, not a promise. Back a remote store with an in-memory cache, hydrate it before mounting, and write through in the background.

import type { StorageAdapter } from "@react-ui-os/core";
function createRemoteStorage(initial: Record<string, unknown>): StorageAdapter {
const cache = new Map<string, unknown>(Object.entries(initial));
const listeners = new Set<(key: string) => void>();
const events = new EventSource("/api/prefs/stream");
events.onmessage = (e) => {
const { key, value } = JSON.parse(e.data) as { key: string; value: unknown };
cache.set(key, value);
listeners.forEach((listener) => listener(key));
};
return {
get<T = unknown>(key: string) {
return cache.has(key) ? (cache.get(key) as T) : null;
},
set(key, value) {
cache.set(key, value);
void fetch(`/api/prefs/${key}`, { method: "PUT", body: JSON.stringify(value) });
},
remove(key) {
cache.delete(key);
void fetch(`/api/prefs/${key}`, { method: "DELETE" });
},
subscribe(listener) {
listeners.add(listener);
return () => void listeners.delete(listener);
},
};
}
// Hydrate before mounting so the first synchronous read already has data.
const initial = await fetch("/api/prefs").then((r) => r.json());
<Desktop apps={apps} theme={theme} storage={createRemoteStorage(initial)} />;

A user’s accent, dock position, and window arrangement now follow them across devices.

Apps don’t have to be a literal array; anything that resolves to an App[] works. Useful when a tenant’s plan determines which features show up.

import { useEffect, useState } from "react";
import type { App } from "@react-ui-os/core";
function App() {
const [apps, setApps] = useState<App[] | null>(null);
useEffect(() => {
fetch("/api/me/apps")
.then((r) => r.json())
.then((data: { id: string; name: string }[]) => {
setApps(
data.map((row) => ({
id: row.id,
name: row.name,
content: () => <RemoteAppFrame appId={row.id} />,
})),
);
});
}, []);
if (!apps) return <SplashScreen />;
return <Desktop apps={apps} theme={theme} />;
}

The window manager re-keys windows when an app’s id changes, so swapping the registry on the fly works without manual cleanup.

A source that calls a remote API on every keystroke is one network hop per character. Debounce inside the source closure.

import { useEffect, useRef } from "react";
import { registerSpotlightSource } from "@react-ui-os/desktop";
export function RemoteSearchSource() {
const cacheRef = useRef<Map<string, unknown[]>>(new Map());
const inflightRef = useRef<AbortController | null>(null);
useEffect(() => {
return registerSpotlightSource("remote-search", (query) => {
if (query.length < 2) return [];
// Synchronous return path: serve cached results immediately.
const cached = cacheRef.current.get(query);
// Background fetch refreshes the cache; the next keystroke (or
// an external listSpotlightSources notification) shows new data.
inflightRef.current?.abort();
const ac = new AbortController();
inflightRef.current = ac;
void fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ac.signal })
.then((r) => r.json())
.then((rows) => {
cacheRef.current.set(query, rows);
})
.catch(() => {
// Aborted by next keystroke, fine.
});
return (cached ?? []).map((row: any) => ({
id: `remote:${row.id}`,
name: row.title,
tagline: row.url,
kindLabel: "Search",
onActivate: () => window.open(row.url, "_blank", "noopener"),
}));
});
}, []);
return null;
}

The first keystroke shows nothing because the cache is cold, but a few characters in the cache catches up and results appear without flicker.

The window manager doesn’t own routing, but it doesn’t fight it either. Route components mount, dispatch openWindow, then return null: the route is just a hook.

// app/(routes)/files/page.tsx
import { useEffect } from "react";
import { useWindowManager } from "@react-ui-os/core";
export default function FilesRoute() {
const { openWindow } = useWindowManager();
useEffect(() => {
openWindow({ kind: "system", systemId: "recents" });
}, [openWindow]);
return null;
}

openWindow collapses repeat calls with the same payload, so refreshing the route does not open a second window.

A system window can be opened with args and the name resolved per instance. Two windows with different args coexist as distinct windows.

import { registerSystemWindow } from "@react-ui-os/desktop";
registerSystemWindow("inspector", {
name: ({ targetId }) => `Inspector: ${String(targetId)}`,
defaultBounds: { w: 360, h: 480 },
content: ({ args }) => <InspectorContent id={String(args?.targetId)} />,
});
// Two open at once, one per target:
openWindow({ kind: "system", systemId: "inspector", args: { targetId: "a-1" } });
openWindow({ kind: "system", systemId: "inspector", args: { targetId: "a-2" } });

See SystemWindowArgs for the underlying serialization rules.

A folder icon should only show up when there’s something in it. appearsAsDesktopIcon is the gate: it reads from the storage adapter so the same predicate sees per-user state in a multi-tenant deploy.

import { registerSystemWindow } from "@react-ui-os/desktop";
import { hasRecents } from "./recents";
registerSystemWindow("recents", {
name: "Recents",
defaultBounds: { w: 560, h: 420 },
content: RecentsFolder,
appearsAsDesktopIcon: (storage) => hasRecents(storage),
});

hasRecents reads storage.get("recents"); when the user empties Recents and the storage write fires its change event, the icon disappears in the same tick.