Migrating from React

Carats is deliberately close to React, so most component code ports directly. The differences below are the ones that actually break a port.

ConceptReactCarats
RuntimeReact, React DOM and a virtual DOMJJSX only; components are functions returning HTML
StateuseState re-renders the componentuse stores a value; the DOM is updated manually
EffectsuseEffect with a dependency arrayafterMount (after load) or beforeMount (immediate), no deps
Event Handlers<button onClick={fn}>addEventListener inside a hook callback
RoutingA file-system router with layouts and nested routesA defineFacets map, composed by importing a layout
Server DatagetServerSideProps on the pageA culet per route plus a Burnish wrapper
Document Headnext/head or the framework metadata exportthis.head inside a function component
Component MetaComponent.displayName, memo and contextdefaultProps on the component; no context or memo
StylingCSS-in-JS or utility classesScoped SASS importing shared design tokens
TypesInterfaces imported per fileGlobal DTO interfaces from src/dto

Event Handlers

The event model is the change that breaks a port most often, so it is worth being precise about it.

// React
<button onClick={() => setOpen(!open)}>Toggle</button>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
// Carats
import { use, afterMount } from '@carats/hooks';

export default function Toggle() {
    const open = use(false);

    afterMount(() => {
        const button = document.getElementById('toggle');

        const stop = open.subscribe((value) => {
            button?.setAttribute('aria-expanded', String(value));
        });

        const onClick = () => open.set(!open.get());
        button?.addEventListener('click', onClick);

        return () => {
            stop();
            button?.removeEventListener('click', onClick);
        };
    });

    return <button id="toggle" aria-expanded="false">Toggle</button>;
}

State

`useState` becomes `use`, and every render that React would have handled becomes an explicit DOM write. State that was local to a component is unchanged in spirit: call `use` at the top level of the component, read it with `get`, and write with `set`.

Routing

The file-system router collapses into a single object. Every route becomes an entry in `defineFacets`, and nested layouts become an explicit import. Because the facets object is shared, a route that is missing from it is missing from both the client and the server, which removes a whole class of bug.

Server Data

`getServerSideProps` becomes a culet, and the page becomes burnished. The function signature is the same shape: the request in, the data out.

// React / Next.js
export async function getServerSideProps({ params }) {
    return { props: { symbol: params.symbol } };
}
// Carats
export default culet<TradeData>('/trade/:symbol', async (req) => {
    return { symbol: req.params.symbol.toUpperCase() };
});
// Carats page
export default Burnish<TradeData>(function (data) {
    return <h1>{data.symbol}</h1>;
});

Document Head

A metadata export becomes `this.head`, and the component has to be a `function` declaration for `this` to exist. A component that is already an arrow function can stay one, at the cost of not contributing head tags.

Styling

CSS-in-JS has no equivalent. Each component imports a SASS file, that file imports `base.sass` for the design tokens, and the tokens live at the top level of that one global stylesheet.

Porting Checklist

  1. Delete react, react-dom and every React-only dependency.
  2. Replace event attributes with addEventListener inside an afterMount or beforeMount callback.
  3. Convert every file-based route into an entry in the facets map, and import a shared layout explicitly.
  4. Move getServerSideProps bodies into culets, and wrap the page with Burnish.
  5. Move useState to use, and add the manual DOM update that the re-render used to do.
  6. Move the head export to this.head on a function component.
  7. Replace CSS-in-JS with SASS files imported by each component.