In Cossack, reusable components are built using the same Cossack base class as pages, but they are used differently within the template. This guide explains how to create, use, and manage state in reusable components.

Creating a Component

To create a component, extend the Cossack class. You can use standard decorators like @Prop, @State, and @ClientState.

Basic Example: Button

import { html } from "@cossackframework/renderer";
import { Cossack, Component, Prop } from "@cossackframework/core";

@Component()
export class Button extends Cossack {
    // 1. Define inputs using @Prop
    @Prop()
    variant: 'primary' | 'secondary' = 'primary';

    @Prop()
    disabled: boolean = false;

    render() {
        // 2. Access props via `this.props` to spread "rest" attributes (like class, style, onClick)
        // Note: We extract known props to avoid spreading them as attributes if they are already handled.
        const { variant, disabled, ...rest } = this.props;

        return html`
            <button 
                data-variant="${this.variant}" 
                ?disabled="${this.disabled}" 
                ...=${rest}
            >
                ${this.children}
            </button>
        `;
    }
}

Components with Internal State: FileUploader

Components can have their own internal state using @ClientState (for UI state) or @State (if connected to a provider, though typically reusable components use client state or props).

import { html } from "@cossackframework/renderer";
import { Cossack, Component, Prop, ClientState } from "@cossackframework/core";

@Component()
export class FileUploader extends Cossack {
    // Inputs from parent
    @Prop()
    uploading: boolean = false;

    @Prop()
    progress: number = 0;

    // Callback for parent action
    @Prop()
    onUpload?: (file: File) => void;

    // Internal UI state
    @ClientState()
    selectedFile: File | null = null;

    render() {
        return html`
            <div class="file-uploader">
                <input type="file" @change="${(e: any) => this.selectedFile = e.target.files[0]}" />
                
                <button 
                    ?disabled="${!this.selectedFile || this.uploading}"
                    @click="${() => this.onUpload?.(this.selectedFile!)}"
                >
                    ${this.uploading ? `Uploading ${this.progress}%` : 'Upload'}
                </button>
            </div>
        `;
    }
}

Using Components

To use a class-based component, you the use the component() helper function from @cossackframework/renderer.

import { html, component } from "@cossackframework/renderer";
import { Button } from "./components/Button";

// Inside your Page's render method:
html`
    <div>
        ${component(Button, { variant: 'secondary', onClick: this.handleClick }, 'Click Me')}
    </div>
`

Passing State (Parent to Child)

Data flows down via properties. When the parent state changes, the render method re-runs, and component() is called with new values. The child component detects these changes and updates efficiently.

// Parent Page
@State() count = 0;

render() {
    return html`
        ${component(CounterDisplay, { count: this.count })}
    `;
}

Passing Actions (Child to Parent)

Events flow up via callbacks passed as properties.

// Parent Page
@Server()
async saveData(data: any) {
    // ... save to DB ...
}

render() {
    return html`
        ${component(MyForm, { '@save': this.saveData })}
    `;
}

Server Actions in Components

Reusable components can define and handle their own @Server actions directly. Components are fully stateful and their state is persisted as part of the Page's state tree.

Basic Example

// src/components/Counter.ts
import { Cossack, Component, Server, State } from "@cossackframework/core";
import { html } from "@cossackframework/renderer";

@Component()
export class Counter extends Cossack {
    @State() count = 0;

    @Server()
    increment() {
        this.count++;
    }

    render() {
        return html`
            <button @click="${this.increment}">Count: ${this.count}</button>
        `;
    }
}

How It Works

When a component with @Server actions is used in a Page:

  1. Initial Render (Server): The component is rendered, and its state is included in the Page's initial state tree under _children.
  2. Client Hydration: The component is restored with its server state.
  3. Action Dispatch: When @Server method is called, the framework sends the action to the server with the component's current state.
  4. State Update: The server executes the action and returns the updated state.
  5. Re-render: The client receives the new state and re-renders the component.

Nested Component State Flow

State is automatically synchronized between nested components and the page:

// Parent Page
@Page()
export class Dashboard extends Cossack {
    render() {
        return html`
            <div>
                ${component(Counter)}
                ${component(Counter)}
            </div>
        `;
    }
}

Each Counter instance maintains its own independent state, managed through unique component IDs (root:0, root:1, etc.).

Accessing Framework Context

Server actions in components can access this.env, this.user, and this.c directly:

@Component()
export class LikeButton extends Cossack {
    @State() liked = false;
    @State() count = 0;

    @Server()
    async toggleLike() {
        // Access database bindings
        const stmt = this.env.DB.prepare(
            "INSERT INTO likes (user_id, post_id) VALUES (?, ?)"
        );
        await stmt.bind(this.user?.id, this.postId).run();

        this.liked = !this.liked;
        this.count += this.liked ? 1 : -1;
    }

    @Prop() postId!: string;

    render() {
        return html`
            <button @click="${this.toggleLike}">
                ${this.liked ? '❤️' : '🤍'} ${this.count}
            </button>
        `;
    }
}

Accessing Context

Components can access the global framework context (env, user, c for request) directly using this.env, this.user, and this.c, without needing them passed as props.

See Framework Context API for complete documentation on accessing environment bindings, authenticated users, and request context from any component.

@Component()
export class UserProfile extends Cossack {
    render() {
        return html`
            <div>
                Logged in as: ${this.user?.name}
                (DB: ${this.env.DB_NAME})
            </div>
        `;
    }
}

Testing

Use @cossackframework/test-utils to test components in isolation.

Installation

pnpm add -D @cossackframework/test-utils

Basic Example

import { render } from '@cossackframework/test-utils';
import { Counter } from './Counter';

test('increments count', async () => {
    const { click, html } = await render(Counter);

    expect(html()).toContain('Count: 0');
    await click('button');
    expect(html()).toContain('Count: 1');
});

Render API

The render function returns a helper object with the following methods:

Method Description
instance The component instance
container The DOM element containing the rendered component
html() Returns the current HTML as a string
click(selector) Dispatches a click event on the matching element
type(selector, text) Types text into an input field
waitForUpdate() Waits for the next component update
unmount() Cleans up and removes the component

Testing with Props

test('renders with custom label', async () => {
    const { html } = await render(Button, {
        props: { variant: 'primary', label: 'Click Me' }
    });

    expect(html()).toContain('Click Me');
});

Testing with Initial State

test('renders with initial state', async () => {
    const { html } = await render(TodoList, {
        state: { todos: ['Task 1', 'Task 2'] }
    });

    expect(html()).toContain('Task 1');
    expect(html()).toContain('Task 2');
});

Testing User Input

test('handles form input', async () => {
    const { type, html } = await render(SearchBox);

    await type('input', 'search query');
    expect(html()).toContain('search query');
});

Testing Server Actions (Mocking)

For components with @Server actions, mock the server methods:

import { render } from '@cossackframework/test-utils';
import { LikeButton } from './LikeButton';

test('toggles like state', async () => {
    const { click, html } = await render(LikeButton);

    // Mock the server method
    (LikeButton.prototype as any).toggleLike = async function() {
        this.liked = !this.liked;
    };

    await click('button');
    expect(html()).toContain('❤️');
});

Cleanup

The unmount helper cleans up the component:

test('cleanup', async () => {
    const { unmount } = await render(MyComponent);

    // ... test code ...

    unmount(); // Removes component from DOM
});

Using with Testing Frameworks

Works with Vitest, Jest, or any TypeScript test framework:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
    test: {
        environment: 'jsdom',
    },
});