React
Primate runs React with server-side rendering, hydration, client navigation, layouts, validation and i18n.
Setup
Install
npm install @primate/react react react-dom
Configure
import config from "primate/config";
import react from "@primate/react";
export default config({
modules: [
react(),
],
});
Components
Create React JSX components in views using TypeScript or JavaScript.
// views/PostIndex.tsx
interface Post {
title: string;
excerpt?: string;
}
interface Props {
title: string;
posts: Post[];
}
export default function PostIndex({ title, posts }: Props) {
return (
<div>
<h1>{title}</h1>
<article>
{posts.map((post, index) => (
<div key={index}>
<h2>{post.title}</h2>
{post.excerpt && <p>{post.excerpt}</p>}
</div>
))}
</article>
</div>
);
}Serve the component from a route:
// routes/posts.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
const posts = [
{ title: "First Post", excerpt: "Introduction to Primate with React" },
{ title: "Second Post", excerpt: "Building reactive applications" },
];
return response.view("PostIndex.tsx", { title: "Blog", posts });
},
});
Props
Props passed to response.view map directly to component props.
Pass props from a route:
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("User.tsx", {
user: { name: "John", role: "Developer" },
permissions: ["read", "write"],
});
},
});Access the props in the component:
interface User {
name: string;
role: string;
}
interface Props {
user: User;
permissions: string[];
}
export default function User({ user, permissions }: Props) {
return (
<div>
<h2>{user.name}</h2>
<p>Role: {user.role}</p>
<ul>
{permissions.map((permission, index) => (
<li key={index}>{permission}</li>
))}
</ul>
</div>
);
}
Request
Import the useRequest hook from app:react to access the current request
inside any component. The hook re-renders the component automatically on
client-side navigation.
import { useRequest } from "app:react";
export default function Page() {
const request = useRequest();
return <p>Current path: {request.url.pathname}</p>;
}The useRequest hook returns a RequestPublic object.
| Property | Type | Description |
|---|---|---|
url |
URL |
current request URL |
query |
Dict<string> |
query string parameters |
headers |
Dict<string> |
request headers |
cookies |
Dict<string> |
request cookies |
Reactivity with hooks
React's hooks provide state management and side effects for interactive components.
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
const doubled = count * 2;
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
<p>Doubled: {doubled}</p>
</div>
);
}
Validation
Use Primate's validated state wrapper to synchronize with backend routes.
import client from "@primate/react/client";
interface Props {
id: string;
counter: number;
}
export default function Counter({ id, counter: initial }: Props) {
const counter = client.field(initial).post(`/counter?id=${id}`);
return (
<div style={{ marginTop: "2rem", textAlign: "center" }}>
<h2>Counter Example</h2>
<div>
<button
onClick={() => counter.update(n => n - 1)}
disabled={counter.loading}
>
-
</button>
<span style={{ margin: "0 1rem" }}>{counter.value}</span>
<button
onClick={() => counter.update(n => n + 1)}
disabled={counter.loading}
>
+
</button>
</div>
{counter.error && (
<p style={{ color: "red" }}>{counter.error.message}</p>
)}
</div>
);
}Add corresponding backend validation in the route:
// routes/counter.ts
import Counter from "#store/Counter";
import route from "primate/route";
import response from "primate/response";
import p from "pema";
await Counter.create();
export default route({
async get() {
const counters = await Counter.find({});
const counter = counters.length === 0
? await Counter.insert({ counter: 10 })
: counters[0];
return response.view("Counter.tsx", {
id: counter.id,
counter: counter.counter,
});
},
async post(request) {
const id = p.string.parse(request.query.get("id"));
const body = p.loose.number.parse(await request.body.json());
await Counter.update(id, { set: { counter: body } });
return null;
},
});The wrapper automatically tracks loading states, captures validation errors, and posts updates on state changes.
Forms
Use client.form from @primate/react/client to wire forms to backend
routes with automatic field-level validation and error display.
// views/LoginForm.tsx
import client from "@primate/react/client";
export default function LoginForm() {
const form = client.form({ initial: { email: "", password: "" } });
return (
<form
method="post"
action="/login"
id={form.id}
onSubmit={form.submit}
>
{form.errors.length > 0 && (
<p style={{ color: "red" }}>{form.errors[0]}</p>
)}
<div style={{ marginBottom: "1rem" }}>
<input
type="email"
name={form.field("email").name}
defaultValue={form.field("email").value}
placeholder="Email"
/>
{form.field("email").error && (
<p style={{ color: "red" }}>{form.field("email").error}</p>
)}
</div>
<div style={{ marginBottom: "1rem" }}>
<input
type="password"
name={form.field("password").name}
defaultValue={form.field("password").value}
placeholder="Password"
/>
{form.field("password").error && (
<p style={{ color: "red" }}>{form.field("password").error}</p>
)}
</div>
<button type="submit" disabled={form.submitting}>
{form.submitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}Add the corresponding route:
// routes/login.ts
import route from "primate/route";
import response from "primate/response";
import p from "pema";
const LoginSchema = p({
email: p.string.email(),
password: p.string.min(8),
});
export default route({
get() {
return response.view("LoginForm.tsx");
},
async post(request) {
const body = LoginSchema.parse(await request.body.form());
// implement authentication logic
return null;
},
});Validation errors from the server are automatically surfaced per-field via
form.field(name).error. The form.submitting flag disables the submit
button while the request is in flight.
Form API
| Property | Type | Description |
|---|---|---|
form.id |
string |
Unique form ID for the id attr |
form.submit |
(event?) => Promise |
Submit handler for onSubmit |
form.submitting |
boolean |
True while the request is in flight |
form.errors |
string[] |
Form-level errors |
form.field(name) |
Field |
Access a named field |
Field API
| Property | Type | Description |
|---|---|---|
field.name |
string |
Field name for the name attr |
field.value |
T |
Initial field value |
field.error |
string|null |
First validation error or null |
field.errors |
string[] |
All validation errors for field |
Layouts
Create layout components that wrap your components using children.
// views/Layout.tsx
import { ReactNode } from "react";
interface Props {
children: ReactNode;
brand?: string;
}
export default function Layout({ children, brand = "My App" }: Props) {
return (
<div>
<header>
<nav style={{ padding: "1rem", backgroundColor: "#f8f9fa" }}>
<h1>{brand}</h1>
<a href="/" style={{ marginRight: "1rem" }}>Home</a>
<a href="/about" style={{ marginRight: "1rem" }}>About</a>
</nav>
</header>
<main style={{ padding: "2rem" }}>
{children}
</main>
<footer style={{
padding: "1rem",
backgroundColor: "#f8f9fa",
textAlign: "center",
}}>
© 1996 {brand}
</footer>
</div>
);
}Next, register the layout via a +layout.ts file:
// routes/+layout.ts
import response from "primate/response";
import route from "primate/route";
export default route({
get() {
return response.view("Layout.tsx", { brand: "Primate React Demo" });
},
});Pages under this route subtree render inside the layout as children.
Internationalization
Create an i18n bridge file that adapts Primate's headless translator to React's reactivity model:
// lib/i18n.ts
import app from "#app";
import i18n from "@primate/react/i18n";
export default i18n(app.i18n);Import and use the bridged translator directly in views:
import t from "#i18n";
export default function Welcome() {
return (
<div>
<h1>{t("welcome")}</h1>
<button onClick={() => t.locale.set("en-US")}>{t("english")}</button>
<button onClick={() => t.locale.set("de-DE")}>{t("german")}</button>
<p>{t("current_locale")}: {t.locale.get()}</p>
</div>
);
}Primate's integration automatically subscribes to locale changes and triggers rerenders when switching languages.
Head Tags
Use Primate's Head component to manage document head elements.
import Head from "@primate/react/Head";
export default function About() {
return (
<div style={{ maxWidth: "800px", margin: "2rem auto", padding: "0 1rem" }}>
<Head>
<title>About Us - Primate React Demo</title>
<meta name="description" content="Learn more about our company" />
<meta property="og:title" content="About Us - Primate React Demo" />
<meta property="og:description" content="Learn more about our company" />
<meta property="og:type" content="website" />
</Head>
<h1>About Us</h1>
<p>
Welcome to our Primate React demo application. This page demonstrates
how to manage document head elements including the title and meta tags
for better SEO and social media sharing.
</p>
</div>
);
}
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| extensions | string[] |
[".tsx", ".jsx"] |
Associated file extensions |
| ssr | boolean |
true |
Enable server-side rendering |
| csr | boolean |
true |
Enable client-side rendering |
Example
import react from "@primate/react";
import config from "primate/config";
export default config({
modules: [
react({
// add `.react.tsx` to associated file extensions
extensions: [".tsx", ".jsx", ".react.tsx"],
}),
],
});