Vite+ + React¶
Vite+ pre-1.0 SPA React 19
A single-page React application powered by ReScript and the Vite+ toolchain. Vite+ (vite-plus) is a unified wrapper over Vite, Vitest, Oxlint, Oxfmt, and Rolldown — one CLI (vp) handles dev, build, preview, and tests. The template ships a working greet form (input + useState + fetch) backed by an offline fallback, plus a vp test smoke suite.
Pick this template when you want a React SPA with the modern unified toolchain. It is the fastest way to get a .res + JSX project running in the browser. If Vite+ blocks you (it is pre-1.0), the README and this page describe a clean fallback to vanilla Vite — you are not locked in.
What You Get¶
my-project/
├── rescript.json # JSX mode "automatic", @rescript/react in bs-deps
├── package.json # ESM, "private": true, "vp" scripts
├── index.html # <script type="module" src="/src/Main.res.mjs">
├── vite.config.mjs # imports defineConfig from "vite-plus" + react()
├── src/
│ ├── Main.res # ReactDOM.Client.createRoot + <App />
│ ├── App.res # form + useState + fetch + Validation
│ ├── Api.res # fetch("/api/greet") + offline fallback
│ ├── Validation.res # zod or sury — selected in the wizard
│ └── __tests__/
│ └── App.test.mjs # smoke test that App is a function component
├── README.md # script docs + About Vite+ section
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + ReScript build + dist/ + .vite/
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vp test
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Affects |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs the compiled |
|
|
React bindings (hooks, components, events) |
|
|
React 19 runtime |
|
|
Form input validation (chosen in the wizard) |
|
|
Unified Vite/Vitest/Oxlint wrapper exposing the |
|
|
Vite+ core engine |
|
|
Direct dep so the documented Vite+ → Vite fallback works without changing dependencies |
|
|
React Fast Refresh + JSX |
|
|
Smoke test runner (also driven through |
|
|
Coverage provider for |
|
Key Files¶
rescript.json¶
Three template-specific bits:
bs-dependenciesincludes@rescript/reactso JSX type-checksjsx.mode = "automatic"so JSX desugars toreact/jsx-runtimerather thanReact.createElementThe validation library (
@rescript/zodshim orrescript-sury) is appended depending on the wizard pick
You should rarely need to touch any of those — they map 1:1 onto modern React + ReScript conventions.
index.html¶
The Vite entry document. Loads /src/Main.res.mjs as a module — Vite resolves the path through its dev server (in dev) or rewrites it to a hashed bundle (in build).
src/Main.res¶
The bootstrap. Three lines: find #root, create a React root, render <App />:
switch ReactDOM.querySelector("#root") {
| Some(rootEl) =>
ReactDOM.Client.Root.render(ReactDOM.Client.createRoot(rootEl), <App />)
| None => Console.error("Could not find root element")
}
src/App.res¶
The interactive demo. A controlled <input> + a submit button + four pieces of useState (name, greeting, error, loading). The submit handler validates the input through Validation.parseGreetForm, then calls Api.greet:
let handleSubmit = async event => {
ReactEvent.Form.preventDefault(event)
switch Validation.parseGreetForm(name) {
| Error(message) =>
setError(_ => Some(message))
setGreeting(_ => None)
| Ok({name: validated}) =>
setError(_ => None)
setLoading(_ => true)
try {
let message = await Api.greet(validated)
setGreeting(_ => Some(message))
} catch {
| JsExn(err) =>
setGreeting(_ => Some("Error: " ++ err->JsExn.message->Option.getOr("unknown")))
}
setLoading(_ => false)
}
}
The validate-then-fetch ordering matters: validation is cheap and synchronous, so you check it before paying for a network round-trip. Errors render inline (<p style={{color: "crimson"}}>) and disable the submit button while the request is in flight.
src/Api.res¶
A thin fetch wrapper aimed at /api/greet. The notable detail is the offline fallback: when no backend is available the function still returns a usable greeting so the form does not appear broken on first run.
let greet = async (name: string): string => {
try {
let response = await fetch("/api/greet", {...})
if response->ok {
let body = await response->json
body["message"]
} else {
`Hello, ${name}! (offline fallback — no backend at /api/greet)`
}
} catch {
| _ => `Hello, ${name}! (offline fallback — fetch failed)`
}
}
When you wire a real backend (graduate to Full-Stack or Monorepo, or stand up the Hono (REST) template), drop the fallback branches.
src/Validation.res¶
parseGreetForm: string => result<greetForm, string> — runs before the fetch call. The zod and sury variants both check that the trimmed name is non-empty and ≤ 80 characters. Bad input lights up the inline error message; valid input proceeds to Api.greet.
src/__tests__/App.test.mjs¶
The smoke test imports the compiled App.res.mjs and asserts on the exported component:
import { describe, expect, it } from "vitest";
import { make as App } from "../App.res.mjs";
describe("App", () => {
it("is a function component", () => {
expect(typeof App).toBe("function");
});
});
This is a minimal sanity check that the component is reachable. It does not render the form. To assert on DOM behavior, add @testing-library/react and a JSDOM environment to the dev dependencies, then write tests that render <App /> and click the submit button.
vite.config.mjs¶
Two lines plus boilerplate:
import { defineConfig } from "vite-plus";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});
Note the defineConfig import comes from vite-plus, not vite. To fall back to vanilla Vite, swap that import (see Notes below).
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The recommended development workflow is two terminals: pnpm res:dev in one to keep .res.mjs outputs current, pnpm dev in the other for the Vite+ HMR server. Vite+ picks up the recompiled modules via its file watcher.
Falling Back to Vanilla Vite¶
When pre-1.0 Vite+ misbehaves (most often a vite/internal resolution failure during vp build), the migration off Vite+ is two edits and zero new dependencies:
In
vite.config.mjs, swap the import:- import { defineConfig } from "vite-plus"; + import { defineConfig } from "vite";
In
package.json, replace thevpscripts with their vanilla equivalents:"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "test": "vitest run", "test:coverage": "vitest run --coverage" }
The vite and vitest packages are already in devDependencies. You can leave vite-plus and @voidzero-dev/vite-plus-core installed (they are inert when the config does not import them) or remove them with pnpm remove vite-plus @voidzero-dev/vite-plus-core.
When Vite+ ships a stable release, the migration back is the same edit in reverse — there is no vite.config syntax difference between the two for a typical React project.
Day-Two Recipes¶
Create a React Component — adding a new ReScript React component and wiring it into
App.resConvert from TypeScript — porting an existing
.tsxcomponent into a.rescomponentOptimize Imports — keeping
App.resandMain.restidy as the SPA growsFind Dead Code — running
reanalyzeto catch unused components
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
Vite+ is pre-1.0.
TemplateVersions.VITE_PLUSpins the0.1.xrange. APIs, defaults, and CLI flags may shift before the stable release. Track the Vite+ changelog when bumping.Known incompatibility: the current pre-1.0 Vite+ does not always resolve
vite/internalcleanly when paired with@vitejs/plugin-react, sovp buildmay fail. The fallback is one edit: invite.config.mjs, replaceimport { defineConfig } from "vite-plus"withimport { defineConfig } from "vite", then swap the npm scripts (dev/build/preview/test) fromvptovite/vite build/vite preview/vitest run. The template already declares a directvitedependency so this swap requires nonpm install.React 19 is the default.
@rescript/reactand the@types/reactfamily are pinned to matching majors inTemplateVersions.kt; bump them together.rescript.json’sjsx.modeis set to"automatic". If you set it back to"classic", you also need to importReactat the top of every.resfile that uses JSX.The Vitest smoke test only verifies that
import { make as App } from "../App.res.mjs"returns a function. It does not actually render the form. Add@testing-library/reactand a JSDOM environment when you need DOM assertions.The form intentionally calls
Validationbefore the network. This keeps the contract simple: the network only sees inputs that already passed local checks. Server-side validation is still required — see the Hono (REST) or Full-Stack templates for the matching backend pattern..gitignoreaddsdist/and.vite/on top of the ReScript defaults so build artifacts and Vite’s local cache do not get committed.package.jsondeclares"private": true. Flip it tofalse(and add a realversionandlicense) only when you actually want to publish the SPA itself to npm — which is unusual for an end-user app.The
vpCLI is a thin wrapper over Vitest forvp test. If you ever need Vitest-only flags that Vite+ does not surface, runvitest rundirectly — thevitestand@vitest/coverage-v8deps are already installed.