Declares a reactive signal local to the island. State is the primary way to store values that change over time and drive re-renders.
Adding local state is a reason to expand ilha(() => JSX) into a builder chain. The component keeps the same island boundary; the builder adds state to it.
Basic usage
Reading and writing
Each state entry becomes a signal accessor — a function that both reads and writes depending on how it is called:
state.count(); // read → returns current value
state.count(5); // write → sets value to 5
state.count((previous) => previous + 1); // update from the latest valueUse the updater form when the next value depends on the previous value. Ilha calls the function once with the latest value and writes its return value.
When a signal is written, the island re-renders automatically. Only the affected island updates — nothing outside it is touched.
If the state value is itself a function, Ilha treats any function passed to the setter as an updater. Return the replacement function from an outer function:
state.callback(() => nextCallback);Initializing from input
The initial value can be a static value or a function that receives the resolved input:
import const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha from "ilha";
import { import zz } from "zod";
const const Counter: Island<{
start: number;
} & Record<string, unknown>, MergeState<Record<never, never>, "count", number>>
Counter = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.input<z.ZodObject<{
start: z.ZodDefault<z.ZodNumber>;
}, z.core.$strip>>(schema: z.ZodObject<{
start: z.ZodDefault<z.ZodNumber>;
}, z.core.$strip>): IlhaBuilder<{
start: number;
} & Record<string, unknown>, Record<never, never>, Record<never, never>, Record<never, never>> (+2 overloads)
input(import zz.function object<{
start: z.ZodDefault<z.ZodNumber>;
}>(shape?: {
start: z.ZodDefault<z.ZodNumber>;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
start: z.ZodDefault<z.ZodNumber>;
}, z.core.$strip>
object({ start: z.ZodDefault<z.ZodNumber>start: import zz.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumbernumber().ZodType<any, any, $ZodNumberInternals<number>>.default(def: number): z.ZodDefault<z.ZodNumber> (+1 overload)default(0) }))
.IlhaBuilder<{ start: number; } & Record<string, unknown>, Record<never, never>, Record<never, never>, Record<never, never>>.state<number, "count">(key: "count", init?: StateInit<{
start: number;
} & Record<string, unknown>, number> | undefined): IlhaBuilder<{
start: number;
} & Record<string, unknown>, MergeState<Record<never, never>, "count", number>, Record<never, never>, Record<never, never>>
state("count", ({ start: numberstart }) => start: numberstart)
.IlhaBuilder<{ start: number; } & Record<string, unknown>, MergeState<Record<never, never>, "count", number>, Record<never, never>, Record<never, never>>.render(fn: (ctx: RenderContext<{
start: number;
} & Record<string, unknown>, MergeState<Record<never, never>, "count", number>, Record<never, never>, Record<never, never>>) => string | RawHtml): Island<{
start: number;
} & Record<string, unknown>, MergeState<Record<never, never>, "count", number>>
render(({ state: IslandState<MergeState<Record<never, never>, "count", number>>state }) => <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>{state: IslandState<MergeState<Record<never, never>, "count", number>>state.count: MarkedSignalAccessor
() => number (+1 overload)
count()}</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>);This is evaluated once at mount time. The initializer is not reactive — it only runs when the island is first created.
Multiple state entries
Chain .state() as many times as needed. Each key becomes a typed accessor on the state object:
import const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha from "ilha";
const const Form: Island<RootInput, MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>>Form = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.state<string, "name">(key: "name", init?: StateInit<RootInput, string> | undefined): IlhaBuilder<RootInput, MergeState<RootState, "name", string>, RootDerived, RootActions>state("name", "")
.IlhaBuilder<RootInput, MergeState<RootState, "name", string>, RootDerived, RootActions>.state<boolean, "submitted">(key: "submitted", init?: StateInit<RootInput, boolean> | undefined): IlhaBuilder<RootInput, MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, RootDerived, RootActions>state("submitted", false)
.IlhaBuilder<RootInput, MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, RootDerived, RootActions>.state<number, "count">(key: "count", init?: StateInit<RootInput, number> | undefined): IlhaBuilder<RootInput, MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>, RootDerived, RootActions>state("count", 0)
.IlhaBuilder<RootInput, MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>>render(({ state: IslandState<MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>>state }) => (
<"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>
{state: IslandState<MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>>state.name: MarkedSignalAccessor
() => string (+1 overload)
name()} — {state: IslandState<MergeState<MergeState<MergeState<RootState, "name", string>, "submitted", boolean>, "count", number>>state.count: MarkedSignalAccessor
() => number (+1 overload)
count()}
</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>
));Inside JSX
Signal accessors can be rendered directly in JSX without calling them. ilha detects signal accessors and calls them automatically, and applies HTML escaping:
import const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha from "ilha";
const const Island: Island<RootInput, MergeState<RootState, "label", string>>Island = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.state<string, "label">(key: "label", init?: StateInit<RootInput, string> | undefined): IlhaBuilder<RootInput, MergeState<RootState, "label", string>, RootDerived, RootActions>state("label", "<b>hello</b>")
.IlhaBuilder<RootInput, MergeState<RootState, "label", string>, RootDerived, RootActions>.render(fn: (ctx: RenderContext<RootInput, MergeState<RootState, "label", string>, RootDerived, RootActions>) => string | RawHtml): Island<RootInput, MergeState<RootState, "label", string>>render(({ state: IslandState<MergeState<RootState, "label", string>>state }) => <"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>{state: IslandState<MergeState<RootState, "label", string>>state.label: SignalAccessor<string>label}</"p": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLParagraphElement>>p>);If you call state.label() explicitly it works the same way — both forms are equivalent inside JSX.
Updating state from events
Put reusable state updates in an action, then call the action from a lowercase native event prop:
import const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha from "ilha";
const const Toggle: Island<RootInput, MergeState<RootState, "open", boolean>>Toggle = const ilha: RootBuilder & DirectIslandFactory & {
html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
raw: (value: string) => RawHtml;
mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
... 5 more ...;
onUncaughtError: typeof onUncaughtError;
}
ilha
.IlhaBuilder<RootInput, RootState, RootDerived, RootActions>.state<boolean, "open">(key: "open", init?: StateInit<RootInput, boolean> | undefined): IlhaBuilder<RootInput, MergeState<RootState, "open", boolean>, RootDerived, RootActions>state("open", false)
.IlhaBuilder<RootInput, MergeState<RootState, "open", boolean>, RootDerived, RootActions>.action<"toggle", undefined, void>(key: "toggle", fn: (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived>) => void): IlhaBuilder<RootInput, MergeState<RootState, "open", boolean>, RootDerived, RootActions & Record<"toggle", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived>) => void>>action("toggle", (_: undefined_, { state: IslandState<MergeState<RootState, "open", boolean>>state }) =>
state: IslandState<MergeState<RootState, "open", boolean>>state.open: MarkedSignalAccessor
(value: SignalSetter<boolean>) => void (+1 overload)
open((open: booleanopen) => !open: booleanopen),
)
.IlhaBuilder<RootInput, MergeState<RootState, "open", boolean>, RootDerived, RootActions & Record<"toggle", (props: undefined, ctx: ActionContext<...>) => void>>.render(fn: (ctx: RenderContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived, RootActions & Record<"toggle", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived>) => void>>) => string | RawHtml): Island<RootInput, MergeState<RootState, "open", boolean>>render(({ state: IslandState<MergeState<RootState, "open", boolean>>state, action: IslandActions<RootActions & Record<"toggle", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived>) => void>>action }) => (
<"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button onclick?: NativeEventHandler<PointerEvent & {
readonly currentTarget: HTMLButtonElement;
}> | undefined
onclick={action: IslandActions<RootActions & Record<"toggle", (props: undefined, ctx: ActionContext<RootInput, MergeState<RootState, "open", boolean>, RootDerived>) => void>>action.toggle: ActionAccessor<undefined, void>toggle}>
Toggle: {var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.String(state: IslandState<MergeState<RootState, "open", boolean>>state.open: MarkedSignalAccessor
() => boolean (+1 overload)
open())}
</"button": WithRawHtmlAttributeValues<JSX.HTMLAttributes<HTMLButtonElement> & ButtonAttributes & {
popovertarget?: string;
popoverTarget?: string;
popovertargetaction?: "hide" | "show" | "toggle";
popoverTargetAction?: "hide" | "show" | "toggle";
}>
button>
));Sharing state across islands
State declared with .state() is local to one island. If you need to share a value across multiple islands, use context() instead, which creates a named global signal.
Notes
- State keys must be unique within the same builder chain.
- The initial value type inferred from the second argument becomes the permanent type of the accessor. Passing a value of a different type later will cause a TypeScript error.
- State is not persisted between page loads unless you use
.hydratable()withsnapshot: trueon the server side.