---
title: Oxlint plugin
description: Lint Ilha islands for primitive order, SSR APIs, and event props that TypeScript cannot check.
---

TypeScript types your props and accessors. It cannot see call order, render mode, or React-shaped event names. The Oxlint plugin flags those mistakes in the editor and in CI.

The plugin ships as `@ilha/router/oxlint`. It is a JavaScript Oxlint plugin — no extra compiler.

## Install

Install Oxlint and `@ilha/router` if the app does not already depend on the router.

```package-install
npm i -D oxlint @ilha/router
```

## Enable the plugin

Point Oxlint at the export and turn on the rules you want.

```json
{
  "jsPlugins": ["@ilha/router/oxlint"],
  "rules": {
    "oxlint-plugin-ilha/pascal-case": "error",
    "oxlint-plugin-ilha/no-conditional-primitive": "error",
    "oxlint-plugin-ilha/no-primitive-outside-island": "error",
    "oxlint-plugin-ilha/prefer-lowercase-events": "error",
    "oxlint-plugin-ilha/no-direct-island-call": "error",
    "oxlint-plugin-ilha/require-ssr-api": "error",
    "oxlint-plugin-ilha/function-in-state": "error",
    "oxlint-plugin-ilha/prefer-plain-handler": "warn"
  }
}
```

Save this as `.oxlintrc.json` at the repo root. Run `oxlint` in your lint script.

The plugin only treats `state`, `derived`, `action`, `effect`, `onError`, and `ilha` as primitives when you import them from `"ilha"`. A router `action()` or a local `action` binding does not match.

## What it checks

| Rule                          | Catches                                                                           |
| ----------------------------- | --------------------------------------------------------------------------------- |
| `pascal-case`                 | `const counter = ilha(...)` — `mount({ Counter })` matches the binding name       |
| `no-conditional-primitive`    | `state` / `derived` / `action` / `effect` / `onError` inside `if`, loops, or `&&` |
| `no-primitive-outside-island` | Primitive calls at module scope or inside a non-component function                |
| `prefer-lowercase-events`     | `onClick` on a native tag or in an `html` template — use `onclick`                |
| `no-direct-island-call`       | `Island(props)` outside another island render                                     |
| `require-ssr-api`             | `await Island(...)` — use `.toStringAsync()` or `.hydratable()`                   |
| `function-in-state`           | `state(fn)` or `set(fn)` when `fn` is a function value                            |
| `prefer-plain-handler`        | `action()` when you never read `.pending`, `.data`, or `.error`                   |

`prefer-lowercase-events` ignores PascalCase components, so `onCheckedChange` on a design-system host stays valid.

Turn the new rules off on files that assert the bad cases:

```json
{
  "overrides": [
    {
      "files": ["**/*.test.ts", "**/*.test.tsx"],
      "rules": {
        "oxlint-plugin-ilha/no-conditional-primitive": "off",
        "oxlint-plugin-ilha/no-primitive-outside-island": "off"
      }
    }
  ]
}
```

## Primitive order

Call primitives at the top of the component, in the same order and kind on every render. Put the branch inside the primitive, not around it.

```tsx twoslash
import { derived, ilha, state } from "ilha";

const Panel = ilha<{ open: boolean }>(({ open }) => {
  const label = derived(() => (open ? "Open" : "Closed"));
  return <p>{label()}</p>;
});
```

```tsx
// oxlint-plugin-ilha/no-conditional-primitive
const Panel = ilha(({ open }) => {
  if (open) {
    const label = state("Open");
    return <p>{label()}</p>;
  }
  return <p>Closed</p>;
});
```

A PascalCase function that is not wrapped in `ilha()` is still a valid primitive frame. It belongs to the island that renders it.

## Render on the server

Direct `Island(props)` calls are for child composition inside another island. For HTML, pick the API that matches the work:

```ts twoslash
import { ilha } from "ilha";

const Hello = ilha<{ name: string }>(({ name }) => <p>Hello, {name}</p>);

const syncHtml = Hello.toString({ name: "Ada" });
const asyncHtml = await Hello.toStringAsync({ name: "Ada" });
const hydrated = await Hello.hydratable(
  { name: "Ada" },
  { name: "Hello" },
);
```

`require-ssr-api` flags `await Hello(...)`. `no-direct-island-call` flags `Hello(...)` at module scope.

## Events and actions

Use lowercase DOM names on native elements. Reach for `action()` only when you need `.pending`, `.data`, `.error`, or cancellation.

```tsx twoslash
import { action, ilha, state } from "ilha";

const Save = ilha(() => {
  const count = state(0);
  const save = action(async (value: string) => {
    await fetch("/api", { method: "POST", body: value });
  });
  const bump = () => count((value) => value + 1);

  return (
    <>
      <button onclick={bump}>{count()}</button>
      <button
        disabled={save.pending}
        onclick={() => save("ok")}
      >
        {save.pending ? "Saving…" : "Save"}
      </button>
    </>
  );
});
```

`prefer-plain-handler` warns if `save` never reads a status field. Store a function in `state` with an updater wrapper: `callback(() => nextFn)`.
