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 browserexport 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 loopconst 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/serverin 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#
- Introduction: what Vincle is for
- First render: your first component
- Elements & attributes:
class,style, booleans - Security model: XSS defences in depth