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.
| Concept | React | Carats |
|---|---|---|
| Runtime | React, React DOM and a virtual DOM | JJSX only; components are functions returning HTML |
| State | useState re-renders the component | use stores a value; the DOM is updated manually |
| Effects | useEffect with a dependency array | afterMount (after load) or beforeMount (immediate), no deps |
| Event Handlers | <button onClick={fn}> | addEventListener inside a hook callback |
| Routing | A file-system router with layouts and nested routes | A defineFacets map, composed by importing a layout |
| Server Data | getServerSideProps on the page | A culet per route plus a Burnish wrapper |
| Document Head | next/head or the framework metadata export | this.head inside a function component |
| Component Meta | Component.displayName, memo and context | defaultProps on the component; no context or memo |
| Styling | CSS-in-JS or utility classes | Scoped SASS importing shared design tokens |
| Types | Interfaces imported per file | Global 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
- Delete react, react-dom and every React-only dependency.
- Replace event attributes with addEventListener inside an afterMount or beforeMount callback.
- Convert every file-based route into an entry in the facets map, and import a shared layout explicitly.
- Move getServerSideProps bodies into culets, and wrap the page with Burnish.
- Move useState to use, and add the manual DOM update that the re-render used to do.
- Move the head export to this.head on a function component.
- Replace CSS-in-JS with SASS files imported by each component.