@forge-go/client-react

中文文档
1.13.7发布于 2026/11/12MITTypeScriptES Module零依赖近 30 天下载 2600

React binding for the Forge client runtime: useSyncExternalStore over the normalized query cache

原始使用文档
文档目录7 节
  1. Configuring a client
  2. What it guarantees
  3. Refreshing a query you don't hold
  4. Live queries
  5. Server rendering
  6. What it does not do yet
  7. Peer dependencies

@forge-go/client-react

The React binding over @forge-go/client-core. Three hooks and a provider, 1.07 kB gzipped.

Everything that decides what a value is — identity, staleness, deduplication, invalidation — was decided in the core, where it is testable without a renderer. What is left here is the narrow job of satisfying useSyncExternalStore's contract without undoing any of it.

import { useQuery, useMutation } from '@forge-go/client-react';
import { useOrderList, useOrderCreate } from './generated/hooks';

function Orders() {
  const { data, status, error, isFetching, refetch } = useQuery(useOrderList, {
    query: { status: 'open' },
  });
  const create = useMutation(useOrderCreate);

  if (status === 'pending') return <Spinner />;

  return (
    <>
      <button disabled={create.isPending} onClick={() => create.mutate({ body: { total: 0 } })}>
        New order
      </button>
      {create.status === 'error' && <Warning error={create.error} />}
      <ul>{data?.map((order) => <Row key={order.id} order={order} />)}</ul>
    </>
  );
}

mutate never rejects: a failure lands in status and error, and the promise resolves with undefined. That is deliberate, and it is why the handler above needs no .catch. A mutation that recorded the error and rejected would ask every caller to remember one — and each forgotten .catch is an unhandledrejection per failed write, which in production means an alert firing about an error the user is already looking at.

When you need to sequence work after a write and must not continue if it did not happen, ask for the rejection by name:

await create.mutateAsync({ body: { total: 0 } }); // throws on failure
router.push('/orders');

Both record identical state. The only difference is who owns the failure.

The first argument is a binding out of the generated hooks.ts (export const useOrderList = query(ops.orderList)), which is a module-level constant. There is no per-endpoint hook to generate and nothing to regenerate when this package changes.

Configuring a client

configureClient() from the core is enough on its own:

import { configureClient, RestTransport } from '@forge-go/client-core';
import { entities } from './generated/ops';
import { client } from './generated/rest';

configureClient({ transport: new RestTransport({ client }), entities });

ClientProvider is optional, and deliberately so. A generated hooks.ts binds at module scope, long before an application exists to hand anything to; requiring a provider would mean a file regenerated from a Go route table had decided how the consuming application does dependency injection. Render one when a global is the wrong answer — a server handling two requests concurrently, a test that must not leak into the next one, an application talking to two backends:

<ClientProvider client={cache}>
  <App />
</ClientProvider>

Resolution is explicit, then provided, then global: useQuery(op, args, {client}) beats a provider, which beats configureClient. With none of the three, getClient() throws by name rather than minting a scratch cache that nothing else can see.

What it guarantees

  • One request per query, not per component. Two components calling useQuery(useOrderList) are one cache entry, one registry mount and one request. They settle together.
  • A write to Order:7 re-renders only what references Order:7. No invalidation is authored in the component; the server declared it.
  • An unchanged entity keeps its object identity across a refetch, so a memo'd row rendering it skips. The container is not guaranteed: a fresh response is a fresh skeleton, and the store does not claim to know that the shape of a list is unchanged. Identity is guaranteed where identity is known.
  • StrictMode's mount / unmount / mount leaves exactly one live subscription and provokes no second request — for the socket as well as for the query.
  • live is opt-in per call site, and shared underneath. Two components on the same live query are one subscription; two different live queries whose entities ride the same channel are one connection.

Refreshing a query you don't hold

useQuery hands back a refetch for the query that component opened. When the write lives somewhere else, use useInvalidate:

import { useInvalidate, useMutation } from '@forge-go/client-react';
import { useOrderArchive, useOrderList } from './generated/hooks';

function ArchiveDialog({ id, onDone }: { id: number; onDone: () => void }) {
  const archive = useMutation(useOrderArchive);
  const invalidate = useInvalidate();

  return (
    <button
      onClick={async () => {
        await archive.mutateAsync({ path: { id } });
        invalidate(useOrderList);
        onDone();
      }}
    >
      Archive
    </button>
  );
}

You name the operation, never the component. useOrderList is a module-level constant out of the generated hooks.ts, so the dialog imports the read it wants refreshed and stays ignorant of whatever list happens to be displaying it.

Three ways to say which:

invalidate(useOrderList);                     // every cached variant
invalidate(useOrderGet, { path: { id: 7 } }); // that one exactly
invalidate.tags(['Order[]', 'Order:7']);      // the tag graph directly

Pass no arguments and you cover every page, filter and sort the cache is holding for that operation, which is usually what you mean once a write has landed. Pass arguments and you get the one query they key, the same key useQuery computed when some other component opened it. One wrinkle. invalidate(op) and invalidate(op, {}) do not name the same query, because a read called with no arguments keys differently from one called with an empty object, and the no-argument form covers both.

invalidate marks queries stale and returns. Mounted ones refetch on the next batch, and several invalidations raised in the same turn coalesce into one round of requests. Unmounted ones keep the flag and refetch when they next mount, so a list on a route you have navigated away from costs you nothing until you go back to it.

When you have to wait, ask for it by name:

await archive.mutateAsync({ path: { id } });
await invalidate.refetch(useOrderList);
onDone();

That starts the mounted matches now and resolves once they have settled, which is what you want before a dialog closes over a list the user is about to read. It rejects on failure, like mutateAsync and unlike mutate. Unmounted matches stay lazy. Refetching twenty cached filter combinations nobody is looking at would spend twenty requests to no purpose.

Reach for tags wherever your operations declare provides. That is the runtime's own model, and it keeps the decision on the server where the schema lives. Most generated reads declare nothing today, though, so the callable form addresses queries by operation and works whether or not the tag graph has an edge to the thing you want refreshed.

useInvalidate(cache) takes the same override useClient takes, and resolves the same way without one, so you invalidate through the cache you read from. The function it hands back is stable for as long as that cache is, so you can put it in a dependency array or hand it to a memo'd child.

Live queries

const { data } = useQuery(useOrderList, { query: { status: 'open' } }, { live: true });

That subscribes to every channel the manifest binds to an entity this query's result type can contain, and releases it when the last consumer unmounts. A frame updates the store directly, so order.updated costs no request at all.

Opt-in, per call site, deliberately. Making it automatic would be fewer characters and two worse properties: a developer reading a component could no longer tell whether it holds a socket, and the application's connection count would become an emergent property of the render tree.

live is an ordinary prop, so it may change. Toggling it subscribes or releases and does nothing to the query — no remount, no refetch, no loading state. Turning it on does not refetch to cover the window it was off for: freshness is the cache's business, and live is not a hidden refetch trigger. The gap that genuinely is the runtime's fault, a dropped socket, is recovered by the core.

It needs a stream runtime — a StreamBinder constructed over the same cache. Without one, {live: true} reports through the cache's onError rather than silently handing back a query that never updates.

Server rendering

import { HydrationBoundary } from '@forge-go/client-react';

<HydrationBoundary state={state} ops={ops}>
  <OrderTable />
</HydrationBoundary>

state is what dehydrate produced on the server, and ops is the generated operation table passed straight through. hydrate needs it because a cache record holds an OperationMeta and needs one to refetch later.

It hydrates during render rather than in an effect. Children read getSnapshot during their own render, which happens after this component's render returns, so a render-phase hydrate is visible to them on the first pass. An effect runs after the tree commits: the first paint would be the loading branch and then flip, which is a visible flash and, on the hydration pass, exactly the mismatch the component exists to remove. It renders no element of its own for the same reason, since a wrapper would change the DOM the two sides are being compared on.

StrictMode double-invokes render, so the boundary remembers which payloads it has already hydrated into which cache. The mark is set after hydration succeeds, never before: React retries a render that threw, and marking first would make the retry skip hydration, throw nothing, and render the children as though it had worked.

getServerSnapshot now reads through QueryCache.peek, which returns what the cache holds without opening a record for a query it has never seen. So renderToString emits real markup where a boundary warmed the cache above the component, and idle where none did. Both sides agree either way, which is the property React actually checks.

A refused payload is handled by reason rather than by message text. A principal mismatch rethrows, so your error boundary catches it and the subtree does not mount. A version or operation mismatch is reported through the cache's onError and the tree renders on, because both are repaired by the queries simply fetching for themselves and blanking a page through every deploy would be worse than the problem. Anything unrecognised rethrows.

What it does not do yet

Devtools.

Peer dependencies

Both React and @forge-go/client-core are peers, not dependencies. Two copies of React means hooks dispatched against the wrong renderer. Two copies of the core means two module-level caches, so the client the application configured is not the one its generated hooks read from — the same defect, one layer down.

React 18 and 19 are both supported and both tested.

版本与发布记录

@forge-go/client-react 共发布 25 个版本,首次发布于 2026/08/07;过去 12 个月发布了 25 个版本。

版本发布日期距今
1.13.7latest2026/11/123 天前
1.13.62026/11/105 天前
1.13.52026/11/069 天前
1.13.42026/11/069 天前
1.13.32026/11/0610 天前
1.13.22026/10/2818 天前

关于 @forge-go/client-react 的常见问题

@forge-go/client-react 是什么?

React binding for the Forge client runtime: useSyncExternalStore over the normalized query cache

如何安装 @forge-go/client-react?

在项目目录运行 npm install @forge-go/client-react,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^18.0.0 || ^19.0.0)、@forge-go/client-core(^1.13.7)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@forge-go/client-react 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@forge-go/client-react 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@forge-go/client-react 可以商用吗?

@forge-go/client-react 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@forge-go/client-react 的下载量有多少?

根据 npm 官方下载统计,@forge-go/client-react 在 2026/10/16 – 2026/11/14 这 30 天内被下载 2,600 次,日均约 87 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@forge-go/client-react 还在维护吗?

最新版本 1.13.7 发布于 2026/11/12(3 天前);至今共发布 25 个版本,其中过去 12 个月发布了 25 个;首次发布于 2026/08/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。