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 components in components
using TypeScript or JavaScript.
// components/PostIndex.tsx
import { useState } from "react";
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";
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";
route.get(() => 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>
);
}
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 validate from "@primate/react/validate";
interface Props {
id: string;
counter: number;
}
export default function Counter({ id, counter: initial }: Props) {
const counter = validate<number>(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 number from "pema/number";
import string from "pema/string";
await Counter.schema.create();
route.get(async () => {
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
});
});
route.post(async request => {
const id = string.parse(request.query.get("id"));
const body = request.body.json(number.coerce);
await Counter.update({ id }, { counter: body });
return null;
});
The wrapper automatically tracks loading states, captures validation errors, and posts updates on state changes.
Forms
Create forms with React hooks for state management and validation.
import { useState } from "react";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<{
email?: string;
password?: string
}>({});
const validateForm = () => {
const newErrors: {email?: string; password?: string} = {};
if (!email) {
newErrors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = "Email must be valid";
}
if (!password) {
newErrors.password = "Password is required";
} else if (password.length < 8) {
newErrors.password = "Password must be at least 8 characters";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: "400px", margin: "2rem auto" }}>
<h2>Login</h2>
<div style={{ marginBottom: "1rem" }}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "1rem"
}}
/>
{errors.email && (
<p style={{
color: "red",
fontSize: "0.875rem",
marginTop: "0.25rem"
}}>
{errors.email}
</p>
)}
</div>
<div style={{ marginBottom: "1rem" }}>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{
width: "100%",
padding: "0.5rem",
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "1rem"
}}
/>
{errors.password && (
<p style={{
color: "red",
fontSize: "0.875rem",
marginTop: "0.25rem"
}}>
{errors.password}
</p>
)}
</div>
<button
type="submit"
disabled={!email || !password}
style={{
width: "100%",
padding: "0.75rem",
backgroundColor: (!email || !password) ? "#ccc" : "#007bff",
color: "white",
border: "none",
borderRadius: "4px",
fontSize: "1rem",
cursor: (!email || !password) ? "not-allowed" : "pointer"
}}
>
Submit
</button>
</form>
);
}
Add the corresponding route:
// routes/login.ts
import route from "primate/route";
import response from "primate/response";
import pema from "pema";
import string from "pema/string";
const LoginSchema = pema({
email: string.email(),
password: string.min(8),
});
route.get(() => response.view("LoginForm.tsx"));
route.post(async request => {
const body = await request.body.json(LoginSchema);
// implement authentication logic
return null;
});
Layouts
Create layout components that wrap your components using children
.
Create a layout component:
// components/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";
route.get(() => response.view("Layout.tsx", { brand: "Primate React Demo" }));
Pages under this route subtree render inside the layout as children
.
Internationalization
Primate's t
function is framework-agnostic. In React, call it directly:
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.
</p>
</div>
);
}
Configuration
Option | Type | Default | Description |
---|---|---|---|
fileExtensions | string[] |
[".tsx", ".jsx"] |
Associated file extensions |
ssr | boolean |
true |
Active server-side rendering |
spa | boolean |
true |
Active client-browsing |
Example
import react from "@primate/react";
import config from "primate/config";
export default config({
modules: [
react({
// add `.react.tsx` to associated file extensions
fileExtensions: [".tsx", ".jsx", ".react.tsx"],
}),
],
});