Hooks

Carats gives you state and a client-side lifecycle through the @carats/hooks package.

There is no useEffect and no dependency array in Carats. A hook body runs on the server and on the client, so anything that touches the DOM or the network has to live inside a hook callback.

use() Hook

The use function provides state management. Unlike React hooks it is not reactive: setting a value does not re-render anything, so nothing on the page updates by itself. The other half of the pair is subscribe, which is how a change becomes visible. Read the value while rendering, write the DOM from a subscriber, and change the value from anywhere.

Basic Usage

import { use } from '@carats/hooks';

export default function Counter(props: { start?: number }) {
    const count = use(props.start ?? 0);

    // get() is a plain read, safe on the server and in the browser
    return <p class="counter">{count.get()}</p>;
}

Because there is no re-render, read the value while rendering and write the DOM from a subscriber:

import { use, afterMount } from '@carats/hooks';

export default function Counter() {
    const count = use(0);

    afterMount(() => {
        const inc = document.getElementById('inc');
        const dec = document.getElementById('dec');
        const label = document.getElementById('count');

        const unsub = count.subscribe((value) => {
            if (label) label.textContent = String(value);
        });

        const increment = () => count.set((n) => n + 1);
        const decrement = () => count.set((n) => n - 1);

        inc?.addEventListener('click', increment);
        dec?.addEventListener('click', decrement);

        return () => {
            unsub();
            inc?.removeEventListener('click', increment);
            dec?.removeEventListener('click', decrement);
        };
    });

    return (
        <div class="counter">
            <p id="count">{count.get()}</p>
            <button id="inc">+</button>
            <button id="dec">-</button>
        </div>
    );
}

State Interface

The use function returns a State object with get, set and subscribe methods. set accepts either a new value or a factory that receives the current one.

type Getter<T> = () => T;
type Factory<T> = (currentValue: T) => T;
type Setter<T> = {
    (fn: Factory<T>): T;
    (value: T): T;
};
type Subscriber<T> = (value: T) => void;
type Subscribe<T> = (subscriber: Subscriber<T>) => () => void;
type State<T> = {
    get: Getter<T>;
    set: Setter<T>;
    subscribe: Subscribe<T>;
};
declare function use<T>(): State<T>;
declare function use<T>(initialState: T): State<T>;

set returns the new value, so you can use the result straight away when you need the updated number immediately.

`use` is also callable outside a component, which is the idiomatic way to share a single piece of state between pages.

Subscribe Method

import { use } from '@carats/hooks';

const state = use(0);

// Returns the unsubscribe function
const unsub = state.subscribe((value) => {
    console.log('Count changed to:', value);
});

subscribe returns an unsubscribe function. Call it from the clear callback of an afterMount or beforeMount hook, otherwise the subscriber stays registered after the page is replaced.

Keep the two concerns apart. A handler changes state and a subscriber writes the DOM. A handler that also writes the DOM gives the element two sources of truth, and the moment one of them is missed the page disagrees with itself.

// Wrong: two places write to the DOM, so they can disagree
const onClick = () => {
    count.set((n) => n + 1);
    if (label) label.textContent = String(count.get());
};

// Right: the handler only changes state, the subscriber only writes the DOM
const unsub =  count.subscribe((value) => {
    if (label) label.textContent = String(value);
});
const onClick = () => count.set((n) => n + 1);

afterMount() Callback

The afterMount callback runs only on the client side, after the load event has been dispatched. Use it to reach DOM elements that the same component created, such as a counter label or a button. It may return a clear callback, which runs before the next hydration pass.

import { afterMount } from '@carats/hooks';

export default function MyComponent() {
    afterMount(() => {
        const button = document.getElementById('my-button');
        const onClick = () => console.log('Button clicked!');
        button?.addEventListener('click', onClick);

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

    return <button id="my-button">Click me</button>;
}

Keep a reference to the exact handler you registered. Passing a fresh arrow function to `removeEventListener` creates a new function identity, so the listener is never actually removed.

beforeMount() Callback

The beforeMount callback runs immediately on the client side, without waiting for the load event. Use it for work that does not need the markup of the component itself, such as timers, key listeners or subscriptions. Like afterMount, it may return a clear callback.

import { beforeMount } from '@carats/hooks';

export default function MyComponent() {
    beforeMount(() => {
        const timer = setInterval(() => console.log('tick'), 1000);
        return () => clearInterval(timer);
    });

    return <div>My Component</div>;
}

Reach for afterMount when the work depends on the markup this component produced, because it runs after the load event and the document is then guaranteed to be complete. Reach for beforeMount when it does not, such as a timer or a key listener, so that the work starts sooner.

clearHydrations()

Clears all registered hydration callbacks by running their clear callbacks. This is mainly useful in tests and when tearing down an app manually.

import { clearHydrations } from '@carats/hooks';

// Runs every registered clear callback and empties the registry
await clearHydrations();

Complete Example

import { use, beforeMount, afterMount } from '@carats/hooks';
import './counter.sass';

interface CounterProps {
    initialValue?: number;
}

export default function Counter(props: CounterProps) {
    const count = use(props.initialValue ?? 0);

    // beforeMount runs immediately and does not need this component's markup
    beforeMount(() => {
        const onKey = (e: KeyboardEvent) => {
            if (e.key === 'ArrowUp') count.set((n) => n + 1);
            if (e.key === 'ArrowDown') count.set((n) => n - 1);
        };
        document.addEventListener('keydown', onKey);
        return () => document.removeEventListener('keydown', onKey);
    });

    afterMount(() => {
        const label = document.getElementById('count');
        const inc = document.getElementById('inc');
        const dec = document.getElementById('dec');

        const unsub = count.subscribe((value) => {
            if (label) label.textContent = String(value);
        });

        const increment = () => count.set((n) => n + 1);
        const decrement = () => count.set((n) => n - 1);

        inc?.addEventListener('click', increment);
        dec?.addEventListener('click', decrement);

        return () => {
            unsub();
            inc?.removeEventListener('click', increment);
            dec?.removeEventListener('click', decrement);
        };
    });

    return (
        <div class="counter">
            <h2>Counter: <span id="count">{count.get()}</span></h2>
            <button id="inc" type="button">+</button>
            <button id="dec" type="button">-</button>
        </div>
    );
}

Type Definitions

type MaybePromise<T> = T | Promise<T>;
type ClearCallback = () => MaybePromise<void>;
type HydrationCallback = () => MaybePromise<ClearCallback | void>;

declare function afterMount(callback: HydrationCallback): void;
declare function beforeMount(callback: HydrationCallback): void;
declare function clearHydrations(): Promise<void>;
declare function use<T>(): State<T>;
declare function use<T>(initialState: T): State<T>;