Skip to content
Vincle

Loading…

    Coming from React

    If you know React, you already know Vincle. The JSX is the same. Components are functions. Props are typed. The difference is what happens next: no virtual DOM, no hydration, no client runtime.

    What stays the same#

    Concept React Vincle
    JSX syntax <div>{value}</div> Same
    Components as functions function Comp({ prop }) Same
    Props <Comp prop="value" /> Same
    children {children} Same
    Fragments <>…</> Same
    className → class Auto-converted Same
    style objects {{ color: "red" }} Same
    TypeScript props interface Props Same

    Port a presentational component#

    A component that only reads props keeps its JSX. Set jsxImportSource to @vincle/core for the migrated files and change its types:

    import type { Renderable } from "@vincle/core";
    type CardProps = { title: string; children?: Renderable };
    export function Card({ title, children }: CardProps) {
    return (
    <section className="card">
    <h2>{title}</h2>
    {children}
    </section>
    );
    }

    Replace ReactNode and ReactElement with Renderable (or JSX.Element for a JSX result), and React.FC<Props> with an ordinary function taking Props. For context, import createContext and useContext from @vincle/core: a context created by React cannot be read by Vincle. Client hooks, refs, and React library components need a behavioral rewrite; changing the JSX compiler does not make them server HTML.

    What changes#

    No client runtime, no hooks#

    useState, useEffect, useRef, useMemo and useCallback do not exist: there is no state, no lifecycle, no re-rendering. Every render is a fresh, deterministic function call. In React, even a Server Component needs a Client Component, and its runtime, the moment it holds state. Vincle has no such fork: whatever must keep happening after the render goes, in the markup, to standard HTML or to a small client library.

    // React: "use client", and React + ReactDOM shipped to run one counter
    "use client";
    export function Counter() {
    const [count, setCount] = useState(0);
    return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
    }
    // Vincle: renders once; Alpine.js does the counting in the browser
    export function Counter() {
    return (
    <div x-data="{ count: 0 }">
    <button x-on:click="count++" x-text="count">
    0
    </button>
    </div>
    );
    }

    Async is native, not Suspense#

    Every component can be async, anywhere, with no <Suspense> boundary and no framework: the await is the boundary. Sibling async branches run in document order, one at a time, and renderToString resolves when every branch finishes.

    async function Comments() {
    const data = await fetchComments();
    return <ul>{data.map(/* … */)}</ul>;
    }

    For the Suspense-shaped tradeoff, shipping the shell immediately and patching Comments in once it resolves, opt in with @vincle/flow:

    import { Defer } from "@vincle/flow";
    function Page() {
    return (
    <Defer target="comments" fallback={<Spinner />} timeout={5000}>
    <Comments />
    </Defer>
    );
    }

    An adapter decides how the patch reaches the page.

    Context and Scope#

    As in React, useContext reads the nearest Provider in the rendered JSX tree. For mutable request state that a plain helper function must read, use the separate Scope API. Both are isolated across concurrent renders.

    // Descendants read the nearest Provider.
    const Auth = createContext({ user: { name: "Guest" } });
    function Page() {
    return <li>{useContext(Auth).user.name}</li>;
    }
    await renderToString(
    <Auth.Provider value={{ user }}>
    <Page />
    </Auth.Provider>,
    );
    // Mutable execution state, readable from a plain helper.
    const Audit = Scope.key<{ user: { name: string } }>("app:audit");
    function logAction(action: string) {
    const { user } = Scope.get(Audit);
    console.log(`${user.name}: ${action}`);
    }
    await Scope.with(() => {
    Scope.set(Audit, { user });
    logAction("viewed dashboard");
    return renderToString(<Page />);
    });

    Event handlers are attributes, not closures#

    A handler is an HTML attribute string evaluated by the browser; a function-valued attribute throws, since there is nothing on the server to attach it to. Vincle never rewrites an attribute it does not recognize, so any library that reads plain HTML attributes (HTMX, Alpine.js, Stimulus, Turbo) plugs straight in, or a standard form needs no JavaScript at all:

    // React: the closure needs the component, and its runtime, in the loop
    const onDelete = useCallback(() => handleDelete(id), [id]);
    <DeleteButton onClick={onDelete}>Delete</DeleteButton>;
    // Vincle: a standard HTML form. Forms only support GET and POST natively,
    // so DELETE is a route, not a verb.
    <form action={`/items/${id}/delete`} method="POST">
    <button type="submit">Delete</button>
    </form>;
    // Vincle + HTMX: HTMX reads the attributes, issues the request, swaps the response.
    <button hx-delete={`/api/items/${id}`} hx-target="#item-list" hx-swap="outerHTML">
    Delete
    </button>;

    An attribute is live the moment the browser parses it, in the first response or in a fragment streamed later by <Defer>: nothing needs to re-render or re-hydrate to wire it up.

    Props that only meant something to the reconciler#

    A handful of React props are not typed, because nothing they talk to exists here: TypeScript refuses them at the call site. Everything else React types is kept. key is accepted and dropped at serialization, so a keyed list built for React still compiles.

    React prop Why it is gone Write instead
    ref No DOM node to hold N/A
    defaultValue, defaultChecked "Initial" only means something to hydration value, checked
    suppressHydrationWarning, suppressContentEditableWarning No hydration to warn about N/A
    radioGroup, autoSave, results, security, classID, unselectable Vendor leftovers no HTML parser reads N/A

    Migration checklist#

    React pattern Vincle equivalent
    useState Props or context: state lives outside the render
    useEffect Not needed: no client lifecycle
    useRef Not needed: no DOM access
    useMemo / useCallback Not needed: no re-renders
    useContext useContext reads the nearest Provider, or its default
    createContext createContext(defaultValue): <Context value> or <Context.Provider value>, plus .Consumer
    React.memo Not needed: every render is a fresh call
    Suspense Async component + <Defer> from @vincle/flow
    ErrorBoundary No equivalent: try/catch at top level, onError in @vincle/flow for streaming
    dangerouslySetInnerHTML Same API: also raw(html) as child
    onClick={() => fn()} HTML <form>, hx-*/data-action/x-on:click, or onclick="fn()" (global fn)
    className / class Both work: HTML name wins if both present, no merge
    htmlFor → for htmlFor remapped to for; for wins if both present
    @types/react Built into @vincle/core: per-element attribute types, nothing to install
    defaultValue / defaultChecked value / checked (see above)
    ref / suppressHydrationWarning Not typed (@vincle/no-refs flags them)
    <img>{caption}</img> Refused: no valid HTML form; use alt, or a <figure>

    What you lose#

    • Hydration: Vincle does not hydrate. If you need interactive components, pair it with plain HTML forms, HTMX, Turbo, Alpine.js, or a client-side framework
    • React ecosystem: MUI, Radix, TanStack Table, React Router all require the React runtime
    • React Server Components: not RSC-aware; use Next.js App Router for RSC workloads

    What you gain#

    • Zero runtime dependencies, one package under 100 KB
    • 2–6× faster than react-dom/server in rendering (measured)
    • Security by default: every value escaped for where it lands and URL schemes filtered by an allowlist, no opt-in required
    • Tree context and execution scopes: Provider ancestry and per-request isolation
    • Any runtime: Node, Bun, Deno, and Cloudflare Workers need no DOM shim

    Next steps#