---
title: Coming from React
---

# 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:

```tsx
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.

```tsx
// 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.

```tsx
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`:

```tsx
import { Defer } from "@vincle/flow";

function Page() {
  return (
    <Defer target="comments" fallback={<Spinner />} timeout={5000}>
      <Comments />
    </Defer>
  );
}
```

An [adapter](/integration/adapters) 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.

```tsx
// 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:

```tsx
// 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](#props-that-only-meant-something-to-the-reconciler))            |
| `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](/guide/comparison))
- **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](/guide/introduction): what Vincle is for
- [First render](/guide/getting-started/first-render): your first component
- [Elements & attributes](/guide/jsx/elements-attributes): `class`, `style`, booleans
- [Security model](/guide/security): XSS defences in depth
