Open-source project
mohebifar/vidact avatar
mohebifar/vidact

Vidact: a compiler that turns React-shaped components into direct DOM updates

A compiler that converts React-compatible codes to VanillaJS with no Virtual DOM

851 stars12 forksTypeScriptMIT

At a glance

What is it?
Vidact compiles function components, JSX and hooks into plain DOM operations so nothing React ships in the bundle. The component function runs once at mount and is never called again, state writes run a fixed list of updaters, and the project itself warns that it is in beta with a growing supported subset.
Who is it for?
Vidact suits a team that wants React-shaped source without a reconciler in the bundle, and it fits well when the component set is small, predictable and free of class components or libraries that reach into React internals. It does not suit an application that depends on the wider React ecosystem, because the compatibility matrix is explicitly limited and unsupported forms fail the build.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The component function runs once and is never called again

The central claim of Vidact is that components run once, when they mount. The compiler reads each function ahead of time, works out which parts of the output depend on which values, and emits a fixed list of small updaters. A state write runs only the updaters that read that state.

That is a different model from React's. In React the component function is called again on every render, and the runtime reconciles the result against the previous tree. Vidact does neither. The DOM is built once, and afterwards a state change triggers a targeted text or attribute update.

The counter example in the documentation makes the mechanics concrete.

tsx
import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <button onClick={() => setCount((value) => value + 1)}>Increment</button>
      <output>Count: {count}</output>
    </div>
  )
}

The source looks exactly like React, and the imports come from react so that editors and type definitions behave normally. But the compiled output builds a div, a button and an output once, and then keeps a single updater that rewrites the text node whenever count changes. Nothing re-runs the component body.

The stated consequences of that design are predictable updates with no surprise re-renders, no stale closures, and no memo needed to keep things fast. Whether those claims hold for a given codebase depends entirely on how much of React's dynamic behaviour the code actually relies on, which is why the compatibility matrix matters more than the marketing framing.

No virtual DOM, no reconciler, no React runtime, and about 8 kB gzipped

The bundle argument is simple. There is no virtual DOM, no reconciler, and no React runtime in the bundle, because there is no React in the output at all. The documentation puts a number on it: a compiled counter is about 8 kB gzipped with the runtime included.

The comparison is not apples to apples in one respect, because the counter example is a single component with two pieces of state. A real application with dozens of components will not ship 8 kB; the runtime is a fixed cost, and the per-component cost is the emitted updaters. The useful reading of the number is that the fixed overhead of the runtime is small, not that any application will be that small.

Vidact is also explicit about what it is not. It is not a React renderer and it does not run React. There is no element tree, no Fiber, and no React DevTools integration. Class components, React.Children, and libraries that reach into React internals are not supported.

Those three exclusions are the real boundary of the project. A React DevTools user will lose their tree inspector. A class component is out. And any dependency that depends on React internals, which is a category rather than a short list, will not work. The documentation points to the compatibility matrix in docs/react-compatibility.md for the full contract, and the compatibility is described as syntax- and target-specific, with unsupported forms failing the build.

Fail the build is the important part of that sentence. A gap in the supported subset is a compile error at the exact source location, not a silent fallback to a slower path.

The Rust compiler and the Vite plugin have to move in step

The build tooling behind Vidact is not a JavaScript toolchain with a Rust corner. The workspace root is a Cargo workspace with members in crates, and the Cargo manifest pins the compiler to a specific upstream project: the workspace metadata names the react-compiler repository as the oxc project, with a crate version of 0.143.0, a pinned revision, a vendored path at vendor/oxc and a patches path.

The workspace also pins the toolchain and the Rust edition. The manifest sets edition 2024 and a rust-version of 1.95, and there is a rust-toolchain.toml at the root. The release profile enables fat LTO, sets codegen-units to 1 and strips symbols.

This level of pinning explains why the documentation insists that versions move together. The compiler and the runtime share a protocol version, and mixed versions fail loudly at startup rather than silently misbehaving. The warning at the top of the documentation says the same thing from the user's side: pin @vidact/runtime, @vidact/vite and @vidact/start to matching versions and run your test suite against the versions you ship.

That last instruction is worth taking seriously. A compiler that produces code for a runtime is a version-coupled pair in the same way a database migration tool and a database server are, and the project treats it that way.

The JavaScript side is a pnpm workspace with packages for the compiler, the runtime, the Vite plugin, the start package, test support and a size measurement package. The scripts show the whole verification chain in one place: the check script runs lint, format, typecheck, test, size and benchmark in sequence, and the test script fans out across Rust, runtime, tools, browser, examples and packages.

Getting started means a generator, a Vite plugin and a deliberate omission of react

The quickest route is the project generator.

sh
npx vidact my-app

It asks for a template and leaves a runnable project. Three templates are offered. The spa template gives you Vite, the compiler plugin and a client-rendered entry point. The start template gives Vidact Start with file routes, loaders, server rendering and hydration. The nitro template is the same full stack app served by Nitro with a preset for every major host.

The manual setup is more instructive, because of one omission. You install the runtime, the Vite plugin and the React-shaped types, and react and react-dom are deliberately not on the list. The reason is given: your source imports from react, and the Vite plugin resolves those imports to the compiled runtime.

sh
pnpm add @vidact/runtime
pnpm add -D @vidact/vite @vidact/react-types @types/react typescript vite

The plugin then compiles every .tsx file in the project.

ts
// vite.config.ts
import { vidact } from '@vidact/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [vidact()],
})

TypeScript is told to leave JSX alone and to type it with Vidact's declarations, which is what the jsxImportSource key is for.

json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@vidact/react-types",
    "types": ["@vidact/react-types", "vite/client"]
  }
}

Mounting looks different from React, and the reason is structural. mountCompiled takes the component itself rather than a JSX element, because a compiled component is a factory that builds its own DOM.

ts
// src/main.ts
import { mountCompiled } from '@vidact/runtime'

import { Counter } from './Counter.tsx'

mountCompiled(Counter, document.querySelector('#app')!)

Development and production builds then follow the Vite commands, pnpm vite and pnpm vite build.

Opt-in feature flags gate the parts of React you probably do not use

Suspense, transitions, form actions and retained UI all work as expected, but they stay out of your bundle until you ask for them. The flags are passed to the plugin.

ts
vidact({ features: ['async', 'concurrent'] })

Four flags are documented. The async flag enables Suspense, lazy and use(promise). The concurrent flag covers useTransition, startTransition, useDeferredValue and flushSync. The actions flag brings in function-valued form action, useActionState, useOptimistic and useFormStatus. The retained-ui flag adds Activity for hiding UI while keeping its state.

The failure mode is friendly. Using a feature that is not enabled produces a compile error that names the flag, so a missing opt-in surfaces at build time rather than at runtime as a missing export.

This is the design decision that makes Vidact plausible for real applications. A React codebase that only needs useState and JSX gets neither the concurrent rendering machinery nor the form action machinery in its output, while a codebase migrating toward Suspense can turn on one flag at a time and measure what it costs.

The trade-off is that the compatibility surface is the union of what Vidact has implemented, not what React has. The documentation states that the supported subset of React is growing, which is a description of a moving target, and that is exactly why the beta warning about pinning versions exists.

Vidact Start adds routing, loaders and hydration on top of the compiler

The full stack package is a separate export path. You swap the plugin for vidactStart() and add a src/routes directory. Each route module exports a Route object with an optional server loader, and the loader result reaches the component as typed loaderData.

tsx
// src/routes/index.tsx
import { defineFileRoute, type RouteComponentProps } from '@vidact/start'

const loader = () => ({ greeting: 'Hello from the server' })

export function HomeRoute({ loaderData }: RouteComponentProps<ReturnType<typeof loader>>) {
  return <h1>{loaderData.greeting}</h1>
}

The typing here is the part that makes the pattern pleasant. RouteComponentProps is parameterised by the return type of the loader, so loaderData is typed from the loader rather than declared by hand, and defineFileRoute binds the two together.

The examples directory shows how much is built on this. There is a todomvc example, a shop example, a docs example and a start example, and the root scripts build each of them in turn alongside the packages. The presence of a todomvc example is a reasonable signal, since that application is a common test of whether a view layer can reproduce React's state update behaviour under a different implementation.

The docs example, the shop example and the start example are also where you would look for how the server rendering and hydration path behaves, and the repository keeps a browser test suite alongside the Rust and runtime tests.

Where Vidact is the wrong tool

The first wrong-tool case is any application whose dependency graph includes a library that reaches into React internals. The documentation excludes those libraries explicitly, and the failure is a build error rather than a degraded runtime, which is better but still means the application cannot ship.

The second case is a codebase that depends on React DevTools for its debugging workflow. There is no element tree and no Fiber, so the inspector shows nothing. For a team whose debugging habits are built around the React DevTools timeline, that is a real cost that does not appear in a bundle size comparison.

The third case is a project that needs stability guarantees over a multi-year maintenance horizon. The documentation warns that Vidact is in beta, that APIs are stabilizing and that the supported subset of React is growing. That is a project describing itself as incomplete, and the pinned upstream compiler revision plus the protocol version coupling between compiler and runtime add operational risk that a mature React dependency does not carry.

On licensing, the project is MIT. The Cargo workspace metadata also declares MIT for the Rust side, and the README badge points at the LICENSE file, so the terms are clear for both halves of the codebase.

The cost of adoption is not the licence. It is the review of every third-party React library in the dependency tree against a compatibility matrix that is, by the project's own description, still growing.

Editorial conclusion

Vidact suits a team that wants React-shaped source without a reconciler in the bundle, and it fits well when the component set is small, predictable and free of class components or libraries that reach into React internals. It does not suit an application that depends on the wider React ecosystem, because the compatibility matrix is explicitly limited and unsupported forms fail the build. Before adopting, read docs/react-compatibility.md against the libraries you already use, pin @vidact/runtime, @vidact/vite and @vidact/start to matching versions as the warning in the documentation requires, and treat the compiled counter at about 8 kB gzipped as the baseline to measure against before you compare with your own bundle.

Frequently asked questions

How do I start a new Vidact project?

Run npx vidact my-app and pick a template. The spa template gives Vite with the compiler plugin and a client-rendered entry point, start gives Vidact Start with file routes, loaders, server rendering and hydration, and nitro serves the same full stack app through Nitro.

Does Vidact use a virtual DOM or ship React?

No. The documentation states there is no virtual DOM, no reconciler, and no React runtime in the bundle, and that Vidact is not a React renderer and does not run React. There is no element tree, no Fiber, and no React DevTools integration.

Which parts of the React API are supported by Vidact?

Compatibility is syntax- and target-specific and is defined in the React compatibility matrix in docs/react-compatibility.md, covering function components, JSX, hooks, DOM, SSR and feature-gated APIs. Unsupported forms fail the build, and class components, React.Children and libraries that use React internals are not supported.

How do I enable Suspense or form actions in Vidact?

You pass feature flags to the plugin, for example vidact({ features: ['async', 'concurrent'] }). The async flag enables Suspense, lazy and use(promise), concurrent enables useTransition, startTransition, useDeferredValue and flushSync, and actions covers function-valued form action, useActionState, useOptimistic and useFormStatus.

Can I mix Vidact package versions?

No. The compiler and runtime share a protocol version, and the documentation requires upgrading @vidact/runtime, @vidact/vite and @vidact/start together. Mixed versions fail loudly at startup instead of misbehaving silently.

What license is Vidact released under?

Vidact is MIT licensed, with the LICENSE file at the root of the repository, and the Cargo workspace metadata declares MIT for the Rust crates as well.

Official sources

  1. Issues
  2. License: MIT
  3. mohebifar/vidact on GitHub
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/mohebifar-vidact.svg)](https://hysenlabs.com/projects/mohebifar-vidact)

Was this useful?