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
    }
  }
})