Component Specification

Overview

Wilco components are server-defined React/TypeScript UI elements that can be dynamically loaded and rendered by the frontend. This specification defines the structure, discovery, bundling, and composition of components.

Design principles

  1. Simplicity: Minimal required files, sensible defaults

  2. Testability: Each component is isolated and independently testable

  3. Debuggability: Full source maps, clear error messages, traceable paths

  4. Reusability: Components can compose other components seamlessly

  5. Flexibility: Optional features enhance but don’t complicate basics

Component structure

Directory layout

Each component is a directory containing a TypeScript entry point, located under the components directory:

src/wilco/examples/
└── <name>/
    ├── index.tsx        # Required: Component entry point
    ├── schema.json      # Optional: Props schema + metadata
    ├── __init__.py      # Optional: Only needed if used as a Python package
    └── *.tsx            # Optional: Additional component files

Component naming

Component names are derived from their filesystem path relative to the components root. Directory names must be valid Python identifiers (letters, numbers, underscores; cannot start with a number).

components/ui/button/         → "ui.button"
components/forms/text_input/  → "forms.text_input"
components/example/counter/   → "example.counter"

The TSX component inside typically uses PascalCase (e.g., TextInput.tsx inside text_input/). Use a library like pyheck for case transformations if needed when generating display names from package paths.

Required files

__init__.py (optional)

An optional marker file. The __init__.py file is not required for component discovery. Components only need an index.tsx (or index.ts) file. Add __init__.py only if the component directory needs to be importable as a Python package.

Future extensibility: This file may later support optional exports for server-side capabilities such as props validation, data fetching, or props transformation.

index.tsx

The entry point for the component. Must export a default React component.

Option 1: Direct implementation

// index.tsx - component defined directly
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}

export default function Button({ label, variant = 'primary', onClick }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {label}
    </button>
  );
}

Option 2: Barrel file (recommended for larger components)

// index.tsx - re-exports from implementation file
export { default } from './Button';

// Button.tsx - actual implementation
export default function Button(props: ButtonProps) {
  // ...
}

The barrel approach allows better code organization with subcomponents:

button/
├── __init__.py
├── index.tsx           # export { default } from './Button'
├── Button.tsx          # Main component
├── ButtonIcon.tsx      # Subcomponent (internal)
└── styles.ts           # Shared styles

All files are bundled together as an atomic package.

Optional files

schema.json

An extended JSON Schema file that defines component props and metadata. When absent, the component accepts any props and has no UI metadata.

{
  "title": "Counter",
  "description": "A simple counter component with increment/decrement controls",
  "version": "1.0.0",
  "type": "object",
  "properties": {
    "initialValue": {
      "type": "number",
      "default": 0,
      "description": "Starting value for the counter"
    },
    "step": {
      "type": "number",
      "default": 1,
      "minimum": 1,
      "description": "Amount to increment/decrement by"
    },
    "min": {
      "type": "number",
      "description": "Minimum allowed value"
    },
    "max": {
      "type": "number",
      "description": "Maximum allowed value"
    }
  },
  "required": []
}

Root-level metadata fields (extensions to JSON Schema):

  • title (string): Human-readable component name

  • description (string): Component description for documentation

  • version (string): Semantic version of the component

Standard JSON Schema fields for props:

  • type: Must be "object"

  • properties: Prop definitions using JSON Schema types

  • required: Array of required prop names

Component composition

The useComponent hook

Components can dynamically load and render other components using the useComponent hook:

import { useComponent } from '@wilcojs/react';

export default function Dashboard() {
  const Counter = useComponent('counter');
  const Button = useComponent('button');

  return (
    <div>
      <Counter initialValue={10} />
      <Button label="Reset" onClick={() => {}} />
    </div>
  );
}

Implementation with React Query

useComponent is built on top of @tanstack/react-query’s useSuspenseQuery, providing:

  • Automatic caching: Components are cached after first load

  • Deduplication: Multiple calls for the same component share one request

  • Background refetching: Stale components can be refreshed automatically

  • Suspense integration: Works seamlessly with React Suspense boundaries

// Simplified implementation
import { useSuspenseQuery } from '@tanstack/react-query';

export function useComponent(name: string): React.ComponentType<any> {
  const { data: code } = useSuspenseQuery({
    queryKey: ['component', name],
    queryFn: () => fetchBundleCode(name),
    staleTime: Infinity,  // Components don't go stale
  });

  return useMemo(() => compileComponent(code, name), [code, name]);
}

The hook requires a QueryClientProvider and Suspense boundary in the component tree:

import { QueryClientProvider } from '@tanstack/react-query';
import { Suspense } from 'react';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Suspense fallback={<Loading />}>
        <Dashboard />
      </Suspense>
    </QueryClientProvider>
  );
}

Error handling

When a component fails to load (not found, bundle error, etc.), the hook throws an error. This should be caught by a React Error Boundary:

import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <Suspense fallback={<Loading />}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

Dependency resolution

Dependencies between components are resolved lazily:

  1. Component A renders and calls useComponent('ui.button')

  2. Hook checks if ui.button is cached

  3. If not cached, initiates fetch (throws promise for Suspense)

  4. Once loaded, component renders with the resolved dependency

No explicit dependency declaration is required. The server does not need to know about component dependencies ahead of time.

Discovery and registry

Component discovery

The backend discovers components by scanning the components directory:

  1. Find all directories containing __init__.py

  2. Check for index.tsx (or index.ts) in each

  3. Optionally load schema.json if present

  4. Register component with name derived from path

# Pseudocode for discovery
for package_dir in find_python_packages(components_root):
    if has_file(package_dir, 'index.tsx'):
        name = path_to_name(package_dir)
        schema = load_optional(package_dir / 'schema.json')
        registry.register(name, package_dir, schema)

Registry refresh

The registry can be refreshed at runtime to pick up new or modified components without server restart (useful for development).

API endpoints

List components

GET /api/bundles

Returns list of available component names:

[
  {"name": "example.counter"},
  {"name": "example.carousel"},
  {"name": "ui.button"}
]

Get component bundle

GET /api/bundles/{name}.js

Returns bundled JavaScript code for the component. The bundle:

  • Is in ESM format

  • Includes inline source maps

  • Has external dependencies (react, react-dom) excluded

  • Is generated on-demand via esbuild

Response headers include Cache-Control: public, max-age=31536000, immutable for aggressive caching. Cache busting is achieved via the content hash (see HTTP Caching).

Get component metadata

GET /api/bundles/{name}/metadata

Returns component metadata from schema.json:

{
  "title": "Counter",
  "description": "A simple counter component",
  "version": "1.0.0",
  "props": {
    "type": "object",
    "properties": {
      "initialValue": {"type": "number", "default": 0}
    }
  }
}

If no schema.json exists, returns minimal metadata:

{
  "title": "counter",
  "description": "",
  "props": {}
}

Bundling

esbuild integration

Components are bundled using esbuild with the following configuration:

  • Format: ESM (ES Modules)

  • Target: ES2020

  • JSX: Automatic (React 17+ JSX transform)

  • External: react, react-dom, react/jsx-runtime

  • Source maps: Inline with original sources

Source map handling

Source maps are rewritten to use a custom URL scheme for debugging:

component://example.counter/Button.tsx

This allows the frontend to:

  1. Identify component sources in stack traces

  2. Map generated code back to original TypeScript

  3. Display meaningful error locations

Module resolution

The frontend provides a module registry that bundled code uses:

window.__MODULES__ = {
  'react': React,
  'react/jsx-runtime': jsxRuntime,
  '@wilcojs/react': wilco,
};

ESM imports in bundled code are transformed to use this registry:

// Original
import { useState } from 'react';

// Transformed
const { useState } = window.__MODULES__['react'];

Type safety

Current: runtime validation

Props are validated at runtime using JSON Schema:

  1. Frontend fetches schema.json via metadata endpoint

  2. PropsEditor UI enforces schema constraints

  3. Invalid props show validation errors

Future: TypeScript generation

Planned but not yet implemented.

TypeScript type definitions will be generated from JSON Schema:

// Generated from schema.json
export interface CounterProps {
  initialValue?: number;
  step?: number;
  min?: number;
  max?: number;
}

This will enable:

  • IDE autocomplete for component props

  • Compile-time type checking

  • Better developer experience

Examples

Minimal component

The simplest possible component:

components/ui/hello/
├── __init__.py
└── index.tsx
// index.tsx
export default function Hello() {
  return <div>Hello, World!</div>;
}

Component with props

A component with typed props and schema:

components/ui/greeting/
├── __init__.py
├── index.tsx
└── schema.json
// index.tsx
interface GreetingProps {
  name: string;
  formal?: boolean;
}

export default function Greeting({ name, formal = false }: GreetingProps) {
  const greeting = formal ? `Good day, ${name}.` : `Hey ${name}!`;
  return <p>{greeting}</p>;
}
{
  "title": "Greeting",
  "description": "Displays a personalized greeting",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Name of the person to greet"
    },
    "formal": {
      "type": "boolean",
      "default": false,
      "description": "Use formal greeting style"
    }
  },
  "required": ["name"]
}

Composable component

A component that uses other components:

components/dashboard/stats/
├── __init__.py
└── index.tsx
// index.tsx
import { useComponent } from '@wilcojs/react';

interface StatsProps {
  title: string;
  metrics: Array<{ label: string; value: number }>;
}

export default function Stats({ title, metrics }: StatsProps) {
  const Card = useComponent('card');
  const Counter = useComponent('counter');

  return (
    <Card title={title}>
      {metrics.map((metric) => (
        <div key={metric.label}>
          <span>{metric.label}</span>
          <Counter initialValue={metric.value} />
        </div>
      ))}
    </Card>
  );
}

Multi-file component

A larger component with internal organization:

components/forms/date_picker/
├── __init__.py
├── index.tsx
├── DatePicker.tsx
├── Calendar.tsx
├── DayCell.tsx
├── utils.ts
└── schema.json
// index.tsx
export { default } from './DatePicker';

// DatePicker.tsx
import Calendar from './Calendar';
import { formatDate } from './utils';

export default function DatePicker(props: DatePickerProps) {
  // Uses internal Calendar component
  return <Calendar {...props} />;
}

Appendix

JSON Schema quick reference

Common property types:

{
  "stringProp": {"type": "string"},
  "numberProp": {"type": "number", "minimum": 0, "maximum": 100},
  "booleanProp": {"type": "boolean", "default": false},
  "enumProp": {"type": "string", "enum": ["a", "b", "c"]},
  "arrayProp": {"type": "array", "items": {"type": "string"}},
  "objectProp": {
    "type": "object",
    "properties": {"nested": {"type": "string"}}
  }
}

Error codes

Error

Cause

ComponentNotFound

No component with given name in registry

BundleError

esbuild failed to bundle the component

SchemaValidationError

Props don’t match schema (runtime)

RenderError

Component threw during React render