Release candidate — 1.0.0-rc.1
← Back to blog

Stitch in Your UI: React, Vue, Svelte, and Solid

Oleksandr Zhuravlov

You declared a stitch — typed input, validated output, retries, drift detection — and now a component has to call it. That's where the boilerplate usually creeps back in: a useState for the data, another for the loading flag, another for the error, a useEffect to fire the call, an AbortController to cancel it on unmount, and — if the call streams — a reducer to fold chunks as they arrive. You wrote that once per framework, and you'll write it again the next time, slightly differently, in the next component.

A stitch already knows its own lifecycle. Every call yields a typed event stream — start → progress → drift → result → done — and await is just sugar over consuming it. The framework bindings take that stream and project it into the host's native reactive primitive, so loading, error, drift, and streaming state become idiomatic state in React, Vue, Svelte, or Solid — without you re-deriving it by hand each time.

One reactive core, four thin bindings

The thing that keeps these four bindings consistent is that they aren't four independent implementations. They all sit on @stitchapi/query-core — a framework-agnostic reactive store that wraps a stitch call and exposes a subscribe / getSnapshot handle, the exact shape React's useSyncExternalStore (and the equivalent primitive in Vue, Svelte, and Solid) consumes directly.

Query-core owns the lifecycle and nothing else: status transitions, cancellation, re-fetching, and the streaming fold. It imports no framework and no node:*, so it's browser- and edge-safe. The snapshot it hands out is identity-stable between real changes — it only produces a new object when something actually changed, which is what keeps useSyncExternalStore from tearing or looping.

That shared core is why the per-framework state shape is the same everywhere:

interface StitchQueryState<T> {
    status: 'idle' | 'pending' | 'streaming' | 'success' | 'error';
    data: T | undefined;
    error: unknown;
    chunks: readonly unknown[];
    isPending: boolean;
    isError: boolean;
    isSuccess: boolean;
    isStreaming: boolean;
}

Learn it once and it transfers. The same isPending / isError / isStreaming booleans, the same chunks list for streaming surfaces, the same status enum — whether you're in a React hook, a Vue composable, a Svelte store, or a Solid store proxy. The binding's job is only to wire that state into the host's render loop and to tear the call down when the component goes away.

React — useStitch / useStitchStream

The React binding is a pair of hooks built on useSyncExternalStore, so they're tearing-free under concurrent rendering. useStitch is the request/response case:

import { useStitch } from '@stitchapi/react';

function Profile({ id }: { id: string }) {
    const { data, isPending, isError, refetch } = useStitch(getUser, {
        params: { id },
    });

    if (isPending) return <Spinner />;
    if (isError) return <Retry onClick={refetch} />;
    return <h1>{data.name}</h1>;
}

The query re-creates and re-fetches when the stitch identity or a structural key of the input changes, and the in-flight run is aborted on unmount — no AbortController to wire by hand.

For a streaming surface (sse / stream), useStitchStream re-renders as each delta chunk arrives — feed those deltas from a server route and the client just maps over chunks. Same result shape; chunks is the running list, and isStreaming stays true until the terminal result:

import { useStitchStream } from '@stitchapi/react';

function Chat({ prompt }: { prompt: string }) {
    const { chunks, isStreaming } = useStitchStream(chat, {
        body: { prompt },
    });
    return (
        <div>
            {chunks.map((c, i) => (
                <span key={i}>{String(c)}</span>
            ))}
            {isStreaming && <Cursor />}
        </div>
    );
}

Vue — reactive composables

The Vue binding exposes the same useStitch / useStitchStream names as composables backed by Vue's reactivity. The input may be a value, a ref, or a getter — a getter re-fetches when its structural key changes — and the in-flight run is aborted when the component's scope is disposed:

import { useStitch } from '@stitchapi/vue';

const { data, isPending, isError, refetch } = useStitch(getUser, () => ({
    params: { id: props.id },
}));

Each field is a ComputedRef, so you can destructure without losing reactivity, and templates unwrap .value for you. The state names — isPending, isError, isStreaming, chunks, status — are exactly the ones from the React hook.

Svelte and Solid — same state, native primitive

The other two bindings carry the same state into each framework's idiomatic shape.

In Svelte, stitchStore / stitchStreamStore are real Svelte stores — built on readable from svelte/store, the surface that's unchanged across Svelte 4 and 5. The run starts on the store's first subscriber and is aborted when the last one leaves, so $user fetches on mount and tears down on teardown. (useStitch / useStitchStream exist as aliases if you prefer the use* naming.)

In Solid, createStitch / createStitchStream reconcile the query store into a Solid createStore, so reading user.state.data inside JSX tracks it fine-grained. Pass an accessor (() => props.id) and the handle is recreated and re-fetched when it changes; the run is aborted on scope teardown via onCleanup.

Different primitives — an external store, a store proxy — but the same state.status, state.chunks, state.isStreaming underneath, because the same query-core sits behind both.

It composes with the data layer you already have

If you already run a query/cache layer, the stitch doesn't fight it — it slots in as the call. The split is clean: the query layer owns view state (caching, invalidation, refetch policy) and the stitch owns the call (types, auth, resilience, drift).

  • @stitchapi/swr runs a stitch as an SWR fetcher; SWR keeps owning the cache.
  • @stitchapi/rtk-query runs a stitch as an RTK Query endpoint, with stream updates flowing through.
  • Each of the framework bindings above also ships an optional TanStack Query adapter (a plain { queryKey, queryFn } object), so you can hand a stitch straight to useQuery without pulling the binding's own hooks into the picture.

So the decision isn't "stitch bindings or my query library." It's which layer owns view state — and the stitch is the call either way.

When this isn't worth it

A few honest caveats:

  • Pick one binding for your stack — don't mix. Each binding targets one framework's reactivity model. Reach for @stitchapi/react in a React app and @stitchapi/vue in a Vue app; there's no reason to layer two of them in the same tree.
  • If a query library already owns your view state, you may not need the binding's hooks at all. Going through @stitchapi/swr, @stitchapi/rtk-query, or the TanStack Query adapter can be the simpler path — let that layer manage state and let the stitch be the queryFn.
  • A single, fire-once call may not justify a hook. For a one-shot call outside a reactive view, await stitch(...) is fine on its own; the binding earns its keep when a component needs to react to loading, error, drift, or streaming state over time.
  • Confirm the exact API for your framework in the docs. The bindings deliberately differ in surface — hooks vs. composables vs. stores vs. store proxies, plain values vs. refs vs. accessors. The shared state shape is consistent, but the call conventions are framework-shaped, so check the page for your stack before wiring it up.

Try it

Install the core plus the binding for your framework — for example, React:

npm i stitchapi @stitchapi/query-core @stitchapi/react

Then see the per-framework guides for the exact signatures: React, Vue, Svelte, and Solid. If you already run a query layer, @stitchapi/swr and @stitchapi/rtk-query let the stitch be the call while that layer keeps the view state — or start from the docs.