npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@client-compose/react

v0.18.1

Published

React and React Native bindings for Client Compose.

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 in

Provide 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 placeholder to registerComponent, 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, or accessibilityState={{ 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: false in the config, or on one screen: useScreen(screen, params, { progressive: false }), <ComposedScreen progressive={false}>. The screen's loading shows 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: renderIslandError shows it if you set one.
  • React Native: its built-in fetch hands over a response all at once, so screens show whole. Pass Expo's streaming fetch to show them as they arrive: fetch: expoFetch in the config, with import { 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.

  • requestInit is 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), onStale is called: call router.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, and onStale is 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's progressive: false) waits for the whole screen instead.

  • If the page also needs the screen (e.g. its meta in Next.js's generateMetadata), open its stream once with streamScreen and 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 response from loadScreen, which renders without streaming.

  • serverComposer(config, { client }) asks the composer for what a request needs at most once, however many components ask (React's cache): openScreen(screen, params) for the page and its metadata, appStrings() to pass on to <AppStringsProvider initial>, and strings(AppStrings) for a Server Component's own words. client works 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)} />;
    }
  • onRenderError in 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> with onRenderError above 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, and onStale(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 for changes.
  • invalidate: cached responses for matching screens are dropped. Screens shown with useScreen / <ComposedScreen> reload, and onInvalidate(refs) is called. A page Server Components rendered calls onStale when 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" }]);
  • createTestConfig gives a provider config whose fetch is the fake composer's, with caching off. It records navigate and invalidate effects; pass onNavigate or onInvalidate to 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, actionError and actionFailure.

  • A screen arrives whole. To test placeholders and islands streaming in, answer one request with streamedScreen("profile", [island("header", …), pending("posts", PostGridV1)]), render, then send(island("posts", …)) and end() (or cut(), 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 renderIsland helper of your own.

  • A paged island fetches its pages from the screen it's on, so test it on one: pagedIsland gives 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 calls onRenderError; 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 set renderIslandError.
  • 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. Set cache: false to 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 DOM
  • next: Next.js App Router, rendered with Server Components (serverComposer)
  • react-native: Expo + React Native