@client-compose/react
v0.18.1
Published
React and React Native bindings for Client Compose.
Maintainers
Readme
@client-compose/react
React and React Native bindings for Client Compose: a protocol for server-composed screens made of client-owned islands.
Tier: core · Protocol: v0 · React 18+ · Works with React DOM, React Native, server rendering and React Server Components
npm install @client-compose/react@client-compose/react uses only React itself: no DOM APIs and no wrapper elements. The same
package and the same registry pattern work on the web, on iOS and Android via React Native, and
with any other React renderer, rendering on the client, the server, or in Server Components:
| Rendering | Use | Data is fetched |
| --- | --- | --- |
| Client only (Vite, CRA, React Native) | <ClientComposeProvider> + <ComposedScreen> or useScreen | in the browser / app |
| Server rendering (Remix / React Router, Next.js Pages Router, Express + renderToString) | loadScreen in your loader, then initialResponse | on the server; the client hydrates without refetching |
| React Server Components (Next.js App Router) | ComposedScreen from @client-compose/react/server | on the server; islands render there |
Register island components
Island props types come from @client-compose/codegen
(set "protocolImport": "@client-compose/react" in its TypeScript target so the generated file only
needs this package). registerComponent renders the component as an element, so island components
can use hooks.
import { ReactIslandRegistry } from "@client-compose/react";
import { FollowButtonV1, ProfileHeaderV2 } from "./islands.generated";
export const registry = new ReactIslandRegistry()
.registerComponent(ProfileHeaderV2, ProfileHeader) // ProfileHeader: (props: ProfileHeaderV2) => ReactNode
.registerComponent(FollowButtonV1, FollowButton)
.registerComponent(PostGridV1, PostGrid, { placeholder: PostGridSkeleton }); // while it streams inProvide a config
<ClientComposeProvider
config={{
registry,
baseUrl: "https://api.example.com/compose",
client: { platform: "web", appVersion: "1.4.0" },
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
onSkipped: (entry, reason) => log("island skipped", entry.key, reason),
onRenderError: (key, error) => report(error, { island: key }),
}}
>
<App />
</ClientComposeProvider>Memoise the config (useMemo) if you build it inside a component, because a new config object refetches.
Render a screen
<ComposedScreen> fetches a screen and renders its islands in order:
<ComposedScreen
screen="profile"
params={{ userId }}
loading={<Spinner />}
renderError={(error, reload) => <Retry onPress={reload} />}
>
{(islands) => <main className="profile">{islands}</main>} {/* optional; default: no wrapper */}
</ComposedScreen>useScreen + <Island> gives you full control over layout, e.g. a React Native FlatList with
pull-to-refresh:
const screen = useScreen("profile", { userId });
if (screen.status === "loading") return <ActivityIndicator />;
if (screen.status === "error") return <Retry onPress={screen.reload} />;
return (
<FlatList
data={screen.islands}
keyExtractor={(island) => island.key}
renderItem={({ item }) => <Island island={item} />}
refreshing={screen.refreshing}
onRefresh={screen.reload}
/>
);useScreen returns { status, islands, response, error, refreshing, streaming, reload }. After the
first response, a failed reload keeps the current islands (status: "ready" with error set).
Streaming
Screens stream (spec §4.6), and render as they do: once the screen's head arrives, its islands show,
each one still on its way as its placeholder, filled in as it comes. streaming is true until the
last arrives.
- Placeholders are yours: pass
placeholdertoregisterComponent, usually a skeleton the island's size so the screen doesn't jump. An island without one shows nothing until it arrives. Say that it's loading (aria-busy, oraccessibilityState={{ busy: true }}in React Native): the placeholder is all there is. - To wait for the whole screen and show it at once, as before streaming, set
progressive: falsein the config, or on one screen:useScreen(screen, params, { progressive: false }),<ComposedScreen progressive={false}>. The screen'sloadingshows until it's all there. - A reload keeps what's shown for an island until its new one arrives, rather than flashing back to its placeholder.
- An island that never arrives (the connection dropped) is an error,
incomplete, retryable:renderIslandErrorshows it if you set one. - React Native: its built-in
fetchhands over a response all at once, so screens show whole. Pass Expo's streaming fetch to show them as they arrive:fetch: expoFetchin the config, withimport { fetch as expoFetch } from "expo/fetch". - Your server decides what's in the head: the islands it marks required, and any whose conditions need data, hold it back (see your composer's README).
Server rendering
Fetch the screen in your loader with loadScreen, and pass the response to <ComposedScreen> (or
useScreen) as initialResponse. The server renders the islands into the HTML, and the client
hydrates without fetching again. loadScreen reads the screen's whole stream, so the response is
plain JSON, and loaders can return it as-is.
// e.g. a React Router / Remix loader
export async function loader({ params, request }) {
return { screen: await loadScreen(serverConfig(request), "profile", { userId: params.userId }) };
}
export default function Profile() {
const { screen } = useLoaderData<typeof loader>();
const { userId } = useParams();
return <ComposedScreen screen="profile" params={{ userId }} initialResponse={screen} />;
}loadScreen(config, screen, params) takes the same config as the provider (registry, baseUrl,
client, headers, …), so on the server you can read auth from the incoming request. When your
framework re-runs the loader (navigation, revalidation), the new initialResponse is used without a
browser request; params with no server response are fetched from the browser as usual.
React Server Components
@client-compose/react/server exports an async Server Component. The screen is fetched and its
islands rendered on the server; islands can be Server Components (no client JavaScript) or Client
Components (files marked "use client").
// app/profile/[userId]/page.tsx
import { ComposedScreen } from "@client-compose/react/server";
export default async function Page({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;
return (
<ComposedScreen
config={{
registry,
baseUrl: process.env.COMPOSER_URL!,
client: { platform: "web", appVersion: "1.4.0" },
headers: async () => ({ Authorization: `Bearer ${await tokenFromCookies()}` }),
requestInit: { cache: "no-store" }, // personalised screens: don't share Next's fetch cache
}}
screen="profile"
params={{ userId }}
fallback={<Spinner />} // streamed while loading; omit to use your loading.tsx
renderError={() => <Oops />} // omit to throw to error.tsx
/>
);
}Import the registry module from Server Components; it has no hooks, so it's safe there. The package's main entry is also safe to import from Server Components: its hooks and components live in a
"use client"module.requestInitis passed to every request, so framework fetch options such as{ next: { revalidate: 30 } }work too.To refresh, use your framework's mechanism (e.g.
router.refresh()).Actions' changes and updates reach the screen in the browser: Client Component islands apply them themselves. The browser's
<ClientComposeProvider>needs a registry that knows every island, for where they hold entities and which islands actions can update. Islands that are Server Components can't be imported there, so declare them instead:browserRegistry.declare(PageHeaderV1, TodoListV1). When what the server rendered is out of date (its screen was invalidated, or a change touched an island the browser can't change),onStaleis called: callrouter.refresh()there. Nothing else needs the server. A page the router brings back from its cache (going back) catches up with the actions taken since it was shown: its Client Component islands apply what they missed, andonStaleis called if one of them, or an island that's a Server Component, missed something only the server can show.The screen streams: islands still pending in its head render as their placeholders, each in its own
<Suspense>, and stream into the page as they arrive.progressive={false}(or the config'sprogressive: false) waits for the whole screen instead.If the page also needs the screen (e.g. its
metain Next.js'sgenerateMetadata), open its stream once withstreamScreenand pass it in, so the component doesn't fetch again. The metadata only needs the head, so the islands still stream:const openArticle = cache((id: string) => streamScreen(config, "article", { articleId: id })); export async function generateMetadata({ params }) { const { head } = await openArticle((await params).articleId); return { title: head.meta?.title, description: head.meta?.description }; } export default async function Page({ params }) { const { articleId } = await params; return <ComposedScreen config={config} screen="article" params={{ articleId }} stream={openArticle(articleId)} />; }Or pass a whole
responsefromloadScreen, which renders without streaming.serverComposer(config, { client })asks the composer for what a request needs at most once, however many components ask (React'scache):openScreen(screen, params)for the page and its metadata,appStrings()to pass on to<AppStringsProvider initial>, andstrings(AppStrings)for a Server Component's own words.clientworks out who's asking for each request, such as its language from a cookie or Accept-Language (preferredLocale(header)). Make it once, in a module of your own:export const composer = serverComposer(config, { client: async () => ({ ...config.client, locale: preferredLocale((await headers()).get("accept-language")) }), }); export async function generateMetadata({ params }) { const { head } = await composer.openScreen("article", await params); return { title: head.meta?.title }; } export default async function Page({ params }) { const p = await params; return <ComposedScreen config={config} screen="article" params={p} stream={composer.openScreen("article", p)} />; }onRenderErrorin this config sees errors thrown while creating an island's element on the server. Errors thrown inside a Server Component island are re-thrown in the browser and caught there; to report those, put a<ClientComposeProvider>withonRenderErrorabove the page.
Strings
Every word an app shows comes from the composer (spec §4.7), so copy changes, and new languages
ship, without a release. An island whose manifest declares strings gets them as t:
function ArticleList(props: IslandProps<typeof ArticleListV1>) {
return <h2>{props.t.heading}: {props.t.articleCount({ count: props.total })}</h2>;
}
registry.registerComponent(ArticleListV1, ArticleList);IslandProps<typeof ArticleListV1> is the island's props, with t when it declares strings.
A sentence with a link in it stays whole for translators: fill the string's params with their own
placeholders, and richText puts the nodes in (it has no hooks, so Server Components can use it):
richText(t.newHere({ link: "{link}" }), { link: <a href="/sign-up">{t.signUp}</a> })useLocale() is the language the client asks for (config.client.locale), for what the app
formats itself: new Intl.DateTimeFormat(useLocale()).
t is made of functions, which can't be sent from a Server Component to a Client Component, so a
Client Component island rendered by @client-compose/react/server gets its t built in the
browser instead. Nothing changes in the island.
The app strings (words outside any island) come from <AppStringsProvider>, inside the provider:
<ClientComposeProvider config={config}>
<AppStringsProvider
cache={stringsCache} // e.g. over AsyncStorage or localStorage
loading={<Splash />}
failed={(retry) => <CouldNotConnect onRetry={retry} />}
>
<App />
</AppStringsProvider>
</ClientComposeProvider>
function RetryButton() {
const t = useAppStrings(AppStrings);
return <button>{t.retry}</button>;
}It fetches them in config.client.locale. With a cache, the app starts with the strings it kept
last time and fetches them again in the background; without one, every launch waits for them.
failed is shown only when there are no strings at all and the composer can't be reached: the
one screen whose words the app has to hold itself. Pass initial with strings already fetched
(e.g. by a Server Component with fetchStrings from @client-compose/react/server), and
viaProxy({ loadStrings }) to fetch them through your server.
Islands with pages
A paged island (spec §4.5) gets its first page as props, and next in its entry while there are
more. usePages fetches them, in the island's own component, from the screen it's on. On the
web, e.g. with a "Show more" button:
function Results(props: ResultListV1) {
const { pages, status, loadMore } = usePages(props);
return (
<section>
<ul>
{pages.flatMap((page) => page.items).map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{status === "more" && <button onClick={loadMore}>Show more</button>}
{status === "loading" && <p role="status">Loading…</p>}
{status === "failed" && <button onClick={loadMore}>Try again</button>}
</section>
);
}Or in React Native, loading the next page as the list nears its end:
function Results(props: ResultListV1) {
const { pages, status, loadMore } = usePages(props);
return (
<FlatList
data={pages.flatMap((page) => page.items)}
renderItem={renderResult}
onEndReached={loadMore}
ListFooterComponent={status === "loading" ? <Spinner /> : null}
/>
);
}status is more, loading, end or failed (with error: the island's error from the
composer, or the request's; loadMore tries again). When the island is rendered with new props
(the screen reloaded, or opened with other params) it starts again from them, and a page still on
its way for the old props is dropped.
Pages are fetched from ${baseUrl}/screens/{screen}/islands/{key} with your headers(). To fetch
them another way, set loadIsland(request) in the config; through your server, viaProxy gives
you one (below).
In tests, answer island requests from handlers.islands["{screen}/{key}"] with
islandResponse(screen, island(key, ResultListV1, props, next)).
Actions
useAction performs an action (spec §5) with its generated descriptor. run never rejects:
it resolves with the result, one of the action's declared errors, or { status: "failed" }.
import { useAction } from "@client-compose/react";
import { FollowUserV1 } from "./actions.generated";
function FollowButton({ userId }: { userId: string }) {
const follow = useAction(FollowUserV1);
const onPress = async () => {
const res = await follow.run({ userId });
if (res.status === "error" && res.error.code === "blocked") showToast("You can't follow this user");
else if (res.status === "failed") showToast("Something went wrong");
};
return <button disabled={follow.pending} onClick={onPress}>Follow</button>;
}When an action succeeds, its effects are applied before run resolves, in this order (spec §5.3):
changes: each entity the action changed is changed in every island showing it (from the generated descriptors'entities), on every screen the app has: shown, cached, and every page a paged island holds. Nothing is fetched. A deleted entity is removed from the list holding it; a screen showing one anywhere else reloads. On a screen rendered by Server Components, Client Component islands change in the browser, andonStale(effects)is called for the rest.update: each island's new entry is swapped into the screen with those exact params, shown or cached; the rest of the screen stays. A screen that can't take it (no island with that key, or one still streaming in) reloads. On a screen rendered by Server Components, as forchanges.invalidate: cached responses for matching screens are dropped. Screens shown withuseScreen/<ComposedScreen>reload, andonInvalidate(refs)is called. A page Server Components rendered callsonStalewhen its screen is invalidated.navigate:onNavigate(ref)is called. Map the screen to a route in your router.
Every action request says which islands the app can draw (registry.capabilities()), so updates
come at versions it knows. Actions are POSTed to ${baseUrl}/actions/{action} with your headers(). To send them another
way, set performAction(request) in the config.
Through your server
To keep a session in an httpOnly cookie, send screens and actions through your own server, which
adds it. createComposerProxy (from @client-compose/client/proxy) is the server half: it checks
what the browser sent, sets the client context and headers, and returns failures as data.
viaProxy is the browser half: it turns the server's functions into the config's fetch,
performAction and loadIsland. With Next.js Server Actions:
// actions.ts
"use server";
const proxy = createComposerProxy({
baseUrl: COMPOSER_URL,
client: async () => ({ platform: "web", appVersion, locale: await getLocale() }),
headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
// e.g. keep a new session in a cookie, and take its token out of the response
onActionResponse: keepSession,
});
export async function performComposerAction(body: unknown) { return proxy.performAction(body); }
export async function loadComposerScreen(body: unknown) { return proxy.loadScreen(body); }
export async function loadComposerIsland(body: unknown) { return proxy.loadIsland(body); }// in a client component
const transport = viaProxy({
performAction: performComposerAction,
loadScreen: loadComposerScreen,
loadIsland: loadComposerIsland, // leave out if no island has pages
});
const config = { registry, baseUrl: "", client, ...transport };To show an action's errors, declaredError(follow.last) gives the declared error the last call
ended with, and isUnexpected(follow.last, ["blocked"]) says whether it ended any other way: the
call failed, or with a declared error the app doesn't handle (such as a code added after it was
built). Show a generic message for those.
const error = declaredError(follow.last);
{error?.code === "blocked" && <Notice>You can't follow this user</Notice>}
{isUnexpected(follow.last, ["blocked"]) && <Notice>Something went wrong</Notice>}Testing
@client-compose/react/testing renders islands in tests the way the app does, against a fake
composer. It has no test framework or renderer of its own, so it works with React Testing Library,
React Native Testing Library and others.
import { ClientComposeProvider } from "@client-compose/react";
import { actionOk, createTestConfig, island, screen } from "@client-compose/react/testing";
const { config, gateway, navigations } = createTestConfig({
registry,
handlers: {
screens: { profile: () => screen("profile", [island("header", ProfileHeaderV2, { name: "Ada" })]) },
actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }, { navigate: { screen: "feed" } }) },
},
});
render(<ClientComposeProvider config={config}><FollowButton userId="u1" /></ClientComposeProvider>);
// ...click Follow, then:
expect(gateway.actionRequests()[0].input).toEqual({ userId: "u1" });
expect(navigations).toEqual([{ screen: "feed" }]);createTestConfiggives a provider config whosefetchis the fake composer's, with caching off. It recordsnavigateandinvalidateeffects; passonNavigateoronInvalidateto run your own too, and any other config (e.g.client) to override the defaults.The response builders are typed by the generated descriptors:
island,islandError,screen,screenError,actionOk,actionErrorandactionFailure.A
screenarrives whole. To test placeholders and islands streaming in, answer one request withstreamedScreen("profile", [island("header", …), pending("posts", PostGridV1)]), render, thensend(island("posts", …))andend()(orcut(), as a dropped connection would).Requests without a handler reject, which fails the test. Answer anything else (such as uploads to storage) with
other.Wrap the provider in your app's own providers (theme, translations, session) in a
renderIslandhelper of your own.A paged island fetches its pages from the screen it's on, so test it on one:
pagedIslandgives a screen with the island (element, to render) and the fake composer's answers (handlers): the screen with its first page, and the pages after it by cursor.const paged = pagedIsland({ island: ResultListV1, first: { props: { items: [ada, grace] }, next: "c2" }, pages: { c2: { props: { items: [linus] } } }, }); const { config, gateway } = createTestConfig({ registry, handlers: paged.handlers }); render(<ClientComposeProvider config={config}>{paged.element}</ClientComposeProvider>);
To test with the words people will see, catalogStrings(catalog) reads a catalog file (codegen's
catalog, e.g. catalog/en.json) as the composer would send it: english.t(ArticleListV1) for an
island's t, english.island(key, ArticleListV1, props) for an island that carries its strings,
english.appStrings for <AppStringsProvider initial>, and english.stringsResponse for the fake
composer's strings handler. It's English, with English plural rules, unless you pass a language.
The fake composer itself (no React) is @client-compose/client/testing.
Previewing islands
Islands take everything they show as props, words included, so each one can be previewed alone, in
each of its states, with a tool such as Storybook. The test kit renders
them there as it does in tests: wrap stories in the provider with a createTestConfig config, the
app strings from catalogStrings, and any providers of your app's own. A story whose island
performs actions says how the fake composer answers them, so failures can be previewed too.
// .storybook/composer.tsx
const english = catalogStrings(catalog);
export function withComposer(handlers: FakeGatewayHandlers = {}): Decorator {
const { config } = createTestConfig({ registry, handlers });
return function WithComposer(Story) {
return (
<ClientComposeProvider config={config}>
<AppStringsProvider initial={english.appStrings}>
<Story />
</AppStringsProvider>
</ClientComposeProvider>
);
};
}
// src/islands/NewTodo.stories.tsx
export const TooLong: Story = {
args: { maxLength: 80, t: english.t(NewTodoV1) },
decorators: [
withComposer({
actions: {
"add-todo": () => actionError(AddTodoV1, { code: "title_too_long", details: { maxLength: 80 } }),
},
}),
],
};Add withComposer() to every story in your preview's decorators, and a story's own decorator, with
its handlers, wraps inside it. The same works in React Native's Storybook, with the client set to the
app's platform (createTestConfig({ registry, client: { platform: "ios", … } })). A paged island is
previewed from a screen, as it's tested: render pagedIsland's element, with its handlers.
The todo example's Next.js app
has a Storybook set up this way: pnpm storybook in examples/todo/next.
Behaviour
- Per-island crash isolation. Each island renders inside its own error boundary and its own
<Suspense>. A component that throws hides only that island and callsonRenderError; the rest of the screen stays. During server rendering (where React ignores error boundaries) the island renders as nothing and recovers in the browser, instead of taking the screen down. - Errored islands (the composer's
status: "error") are omitted unless you setrenderIslandError. - Unknown islands (type, version or status this app doesn't know) are skipped and reported
through
onSkipped. - Caching. Responses are reused for the composer's
cache.maxAge, per provider, keyed by screen and params. Returning to a screen renders immediately without a request.reload()always refetches. Setcache: falseto turn this off. - Params can be a new object each render; requests are keyed by their contents.
- Requests are cancelled when the screen or params change, or the component unmounts.
Examples
The todo example has one app per kind, each working with either of its composers:
web: Vite + React DOMnext: Next.js App Router, rendered with Server Components (serverComposer)react-native: Expo + React Native
