Contents
Components
Carats components are similar to React components but with some key differences in how they handle attributes and events.
Component Structure
Components are .tsx files that export a default function. They receive a single props object, may declare defaultProps, and render props.children where the children should appear. They can import any file Vite understands, which is how SASS is attached to a component.
import './_layout.sass';
export default function Layout(props: JSX.ComponentProps) {
return (
<>
<header>Hello World</header>
<main>{props.children}</main>
<footer>Carats</footer>
</>
)
}
Example Component
import './home.sass';
export default function Home() {
return (
<div class="home">
<h1>Welcome to Carats</h1>
<p>Build amazing applications with JJSX</p>
</div>
);
}
The SASS file is imported for its side effect, which is what attaches the stylesheet to the page. A component may import anything Vite understands: SASS, CSS, JSON or images.
Props and Children
Props are passed to components as expected, and children are contained within the component tags. A burnished component receives its culet's return value as props instead.
export default function Card(props: { title: string; children?: JSX.Element }) {
return (
<article class="card">
<h2>{props.title}</h2>
{props.children}
</article>
);
}
// Usage
<Card title="My Card">
<p>Card content here</p>
</Card>
Default Props
defaultProps fills in props that were not supplied. This is the usual way to make a page renderable during a test, where no server props are available.
import { CaratsComponent } from '@carats/render';
const Profile: CaratsComponent<User> = function (user) {
return <h1>{user.name}</h1>;
};
Profile.defaultProps = {
id: '0',
name: 'Guest',
username: 'guest',
email: '',
phone: '',
website: ''
};
export default Profile;
Dynamic Head Tags
Components can provide dynamic head tags (title, meta) by assigning to this.head inside the component function. The tags are collected during server-side rendering and injected into the <!--app-head--> placeholder of the HTML template.
import { CaratsComponent } from '@carats/render';
export default function Home(this: CaratsComponent) {
this.head = <>
<title>Home Page</title>
<meta name="description" content="Welcome to my app" />
</>;
return <div>Home</div>;
}
this only exists in a component declared with the function keyword. An arrow function has no own this, so it cannot assign this.head; use a function declaration, or drop the head tags and keep the component as an arrow function. A regular arrow component is typed as CaratsComponent when it is used directly in a route, and as CaratsComponentWithThis when the annotation is needed for a plain function expression.
The `CaratsComponent` type must be imported; it is exported by `@carats/render`. The `this` parameter is only a type annotation and is erased at compile time.
HTML Attributes
HTML attributes are preserved as-is and are treated as strings. Unlike React, you cannot pass a function to an event attribute such as onclick or onsubmit; use addEventListener inside an afterMount callback instead. This is the single most common difference when porting React code.
Incorrect (won't work)
<button onclick={() => handleClick()}>Click me</button>
The function is not a valid HTML attribute value, so it ends up in the markup as garbage and nothing is ever called.
Correct
import { afterMount } from '@carats/hooks';
export default function MyButton() {
let handler: (() => void) | undefined;
afterMount(() => {
const button = document.getElementById('my-button');
handler = () => console.log('Clicked!');
button?.addEventListener('click', handler);
return () => button?.removeEventListener('click', handler!);
});
return <button id="my-button">Click me</button>;
}
The same rule applies to `onsubmit`, `oninput`, `onchange` and every other event attribute. Give the element an `id` and look it up inside a hook callback.
Burnished Components
Burnished components automatically fetch data from their culet. Wrap your component with the Burnish function from @carats/render to enable this feature. The wrapper matches the component's route to a culet of the same route, so both must be registered under an identical route string.
With this context
import { Burnish } from '@carats/render';
export default Burnish<User>(function (user) {
this.head = <title>{user.name}</title>;
return <h1>Hello {user.name}</h1>;
});
With recast option
The framework caches the most recent server props for a route. Pass { recast: true } to refetch on every render instead of reusing the cached value.
export default Burnish<User>((user) => <h1>Hello {user.name}</h1>, { recast: true });
A burnished component still accepts props like any other component, which is what makes it testable without a server.
Client Entrypoint
The entrypoint mounts the facets in the browser and then starts client-side rendering. clientRender() is required whenever in-app routing is enabled, and it should be called after mount.
import { mount, clientRender } from '@carats/csr';
import facets from './facets.cara';
import './base.sass';
mount(facets);
clientRender();
The global stylesheet is imported here rather than in a page, because it holds the design tokens and the base resets that every page relies on.
Vite Configuration
The client Vite config builds from src/client and emits to dist/client with a manifest, which lets the server reference the hashed entry file.
import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
publicDir: path.resolve(import.meta.dirname, 'public'),
root: path.resolve(import.meta.dirname, 'src/client'),
base: '/',
appType: 'custom',
server: {
middlewareMode: true
},
build: {
outDir: path.resolve(import.meta.dirname, 'dist/client'),
emptyOutDir: true,
manifest: true,
minify: true,
rollupOptions: {
treeshake: true
}
}
})