Forge
1.x
Docs/Forge/Framework adapters
Open

Reading10 min
Updated5 Aug 2026
Sourcev1/web-client/adapters.mdx

Three adapters, each around a kilobyte gzipped. Everything that decides what a value is — identity, staleness, deduplication, invalidation — was decided in @forge-go/client-core, where it is testable without a renderer. The adapters do the narrow job of moving those values into a framework's reactivity without it rewriting them on the way through.

Careful

None of these packages is published to npm yet. See Installation.

Setup01

The runtime needs a transport and the generated entities table. One call, anywhere that runs before your first render:

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

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

A provider 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 your 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.

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

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

Resolution is explicit, then provided, then global: an options-level client beats a provider, which beats configureClient. With none of the three, getClient() throws by name rather than minting a scratch cache nothing else can see.

Querying and mutating02

tsx
import { useQuery, useMutation } from '@forge-go/client-react';
import { useListOrders, useCreateOrder } from './generated/hooks';

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

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

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

The first argument is a binding out of the generated hooks.ts — a module-level constant, not a hook. There is no per-endpoint hook to generate and nothing to regenerate when the adapter changes.

APIReactVueAngular
ReaduseQueryuseQueryinjectQuery
WriteuseMutationuseMutationinjectMutation
ProviderForgeProviderprovideForgeClientprovideForgeClient
Client accessuseForgeClientuseForgeClientinjectForgeClient

Arguments are static in React and reactive in Vue and Angular: pass a getter (() => ({...})) so a changing filter re-keys the query.

mutate never rejects

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

When you must not continue if the write did not happen, ask for the rejection by name:

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

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

Live queries03

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

That subscribes to every channel the manifest binds to an entity this query's result can contain, and releases it when the last consumer unmounts.

Opt-in governs socket ownership, not store visibility

This is the distinction that otherwise costs an afternoon.

{ live: true } decides whether this call site opens and holds a channel. It does not decide which queries see the resulting data.

  • No live call site anywhere means no socket. Nothing subscribes, nothing arrives.

  • Once any live query opens a channel, a frame writes to the shared entity record — and every query depending on that record re-renders, live or not.

So a detail view with no live flag updates when a list elsewhere on the page is live and a frame patches the order they share. That is not a leak. That is the normalized store doing exactly its job: there is one Order:7, and a write to it reaches everything referencing it. If you want a view not to observe an entity, the answer is for it not to reference that entity, not for it to opt out of a socket it never opened.

Why opt-in at all

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.

Underneath, sharing is aggressive. Two components on the same live query are one subscription. Two different live queries whose entities ride the same channel are one connection — one socket per (endpoint, principal), multiplexed by channel, closed on the last unmount.

Toggling it

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, which invalidates every tag bound to the channel on reconnect.

Warning

Live queries need 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.

They also need a manifest whose streams table is populated, which today means generating from a document carrying stream bindings. See Invalidation.

Guarantees04

  • One request per query, not per component. Two components calling useQuery(useListOrders) 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 memoised row skips. The container is not guaranteed — a fresh response is a fresh skeleton.

  • StrictMode's mount / unmount / mount leaves exactly one live subscription and provokes no second request, for the socket as well as the query. Naive ref counting closes the socket on the phantom unmount and the live query silently stops; it reproduces only in development, so it gets reported as "works in prod, broken locally" and dismissed. It has a dedicated test.

Server rendering05

Not supported yet. On a server render React's useQuery returns idle and issues no request: the runtime ships no store serialisation, so a hydrating client necessarily starts empty, and returning server-fetched data from getServerSnapshot would be a guaranteed hydration mismatch rather than an optimisation.

Peer dependencies06

Both the framework 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 your application configured is not the one its generated hooks read from — the same defect, one layer down.

AdapterFramework range
@forge-go/client-react^18.0.0 || ^19.0.0
@forge-go/client-vue^3.3.0
@forge-go/client-angular>=17.0.0