oem 3.2.0

Abstract

OEM is an agent-first UI framework and toolkit engineered for human-AI collaboration. It provides a declarative syntax for composing reactive UIs in 100% TypeScript.

The following documentation describes the core concepts, libraries, and conventions of OEM. The sectsions in this document are normative unless otherwise specified.

GITHUB | NPM

Table of Contents

Install

To get started with OEM, install the package from npm:

npm install @linttrap/oem

Quick Example

// define a template engine
const [tag, trait] = Template({
  style: useStyleTrait,
  event: useEventTrait,
  text: useTextTrait,
})

// define state
const count = State(0);

// define your dom
const counter = tag.div(
  trait.style('display', 'flex'),
  trait.style('alignItems', 'center'),
  trait.style('gap', '16px'),
  tag.span(
    trait.text(count.$val),
    trait.style('fontSize', '48px'),
    trait.style('fontWeight', '700'),
    trait.style('color', '#555555'),
  ),
  tag.button(
    trait.text('+'),
    trait.event('click', count.$reduce((n) => n + 1)),
    trait.style('fontSize', '24px'),
  ),
);

// that's it! You just defined: behavior, state, and presentation in one cohesive block of code. 

Core Library

Template

Overview

The template module allows you to create your own template engine. It is the core mechanism that supports OEM's unique approach to declarative, reactive applications that combine HTML, Styling, Logic and Behavior.

Key Exports

Template<P>

// Import Template and some traits from OEM
import { Template, useEventTrait, useTextContentTrait, useStyleTrait } from '@linttrap/oem';

// Create template and add traits
export const [tag, trait] = Template({
  event: useEventTrait,
  text: useTextContentTrait,
  style: useStyleTrait,
});

// Now you can generate elements with the tag proxy and apply available traits
const button = tag.button(
  trait.event('click', () => console.log('Clicked!')),
  trait.text('Click Me'),
  trait.style('backgroundColor', 'blue'),
);

Tag Proxy (First element of tuple)

Supported Element Types
Element Creation Function

Trait Proxy (Second element of tuple)

Trait Applier Function

Type Definitions

TemplateTraitFunc
TemplateTraitApplier
TemplateReturnType<P>

Implementation Details

Automatic Cleanup System

The template module uses a sophisticated cleanup system:

SVG Element Detection

SVG elements are created using createElementNS with the SVG namespace. The module maintains a hardcoded set of common SVG tag names for detection.

Traits

A trait is any function that takes an element as its first parameter, applies some behavior or configuration to it and returns a cleanup function. Traits can be defined in the config object passed to Template() and are accessed via the trait proxy.

Here's an example of the implementation of the Style Trait

export function useStyleTrait(
  el: HTMLElement,
  prop: keyof CSSStyleDeclaration | `--${string}`,
  val: (() => string | number | undefined) | (string | number | undefined),
  ...rest: (StateType<any> | Condition)[]
) {
  const states = extractStates(val, ...rest);
  const conditions = extractConditions(...rest);
  const apply = () => {
    const _val = typeof val === 'function' ? val() : val;
    const applies = conditions.every((i) => (typeof i === 'function' ? i() : i));
    if (applies) {
      (prop as string).startsWith('--')
        ? el.style.setProperty(prop as string, _val as string)
        : (el.style[prop as any] = _val as any);
    }
  };
  apply();
  const unsubs = states.map((state) => state.sub(apply));
  return () => unsubs.forEach((unsub) => unsub());
}

Conditional Patterns

IMPORTANT: OEM prescribes using explicit conditions rather than ternary expressions when applying traits.

Using Conditions (Preferred)

All traits accept ...rest: (StateType<any> | Condition)[] parameters. Use $test() from @/core/util to create conditions:

import { $test } from '@linttrap/oem';

// ✅ CORRECT: Use separate trait calls with conditions
trait.style('opacity', '0.6', $test(disabled)),
trait.style('opacity', '1', $test(!disabled)),

// ✅ CORRECT: Multiple conditions
trait.style('backgroundColor', 'red', $test(isError && !disabled)),

// ✅ CORRECT: Conditional event handlers
trait.event('click', handleClick, $test(!disabled)),

// ✅ CORRECT: Conditional attributes
trait.attr('disabled', 'true', $test(disabled)),

Note: State objects come with a built-in $test method that creates a condition based on the state value (e.g. state.$test(true) creates a condition that checks if the state value is true). See State.md for more details.


#### Avoiding Ternary Expressions (Anti-pattern)

```typescript
// ❌ INCORRECT: Do not use ternary expressions
trait.style('opacity', disabled ? '0.6' : '1'),

// ❌ INCORRECT: Do not use conditional spreads
...(!disabled ? [trait.event('click', handleClick)] : []),

// ❌ INCORRECT: Do not use inline conditionals
trait.style('color', isError ? 'red' : 'blue'),

How Conditions Work

Traits extract conditions using extractConditions() (see @/core/util) and only apply when all conditions evaluate to true:

  1. Condition Creation: $test(value, expected = true) creates a condition that checks if value === expected
  2. Condition Extraction: Traits filter rest parameters for objects with type === '$test'
  3. Condition Evaluation: All conditions must pass for the trait to apply
  4. Reactive Updates: When states change, conditions are re-evaluated

This pattern ensures:


State

Overview

The state function is an an event bus with reactive state management convention used by the OEM framework. It implements a publish-subscribe pattern with support for reducing, testing and augmenting with custom methods. State is designed to be simple and flexible, allowing you to manage reactive data in your applications without the overhead of more complex state management libraries.

Purpose

State solves the problem of managing reactive data in applications where UI components need to automatically update when data changes. It provides a lightweight alternative to more complex state management libraries, with built-in support for:

Use this module when you need to manage any state, especially with reactive data binding between your application state and UI components. State objects should live outside of function components to ensure they are shared across the application and not recreated on each render.

Key Exports

State<T, M>

// Basic usage
const count = State(0);
count.sub((value) => console.log('Count changed:', value));
count.set(5); // Logs: "Count changed: 5"

// With custom methods
const counter = State(
  { count: 0 },
  {
    increment: (state) => {
      state.reduce((prev) => ({ count: prev.count + 1 }));
    },
    incrementBy: (state, amount: number) => {
      state.reduce((prev) => ({ count: prev.count + amount }));
    },
  },
);

counter.increment(); // Increments count by 1
counter.incrementBy(5); // Increments count by 5
counter.$increment()(); // Deferred increment

Note: the $-prefixed methods (e.g. $increment) are automatically generated for each custom method and return a closure that can be used for deferred execution, such as in event handlers.

StateType<T>

Methods
val()
set(atom: T)
reduce(cb: (prev: T) => T)
sub(cb: (atom: T) => any)
const unsub = count.sub((value) => console.log(value));
// Later: unsub() to stop listening
test(predicate, checkFor?)
const isZero = count.test(0); // true if count is 0
const isPositive = count.test((v) => v > 0);
Deferred Execution Methods ($ prefix)

Each core method has a dollar-prefixed version ($val, $set, $reduce, $test, $call) that returns a closure for deferred execution. These closures include:

Usage Example:

const getDouble = count.$reduce((prev) => prev * 2);
// Later: getDouble() executes the reduction
Custom Methods

State supports extending functionality with custom methods via the second parameter. Custom methods:

Usage Example:

type CounterState = { count: number; name: string };

const counter = State<CounterState>(
  { count: 0, name: 'MyCounter' },
  {
    increment: (state) => {
      state.reduce((prev) => ({ ...prev, count: prev.count + 1 }));
    },
    incrementBy: (state, amount: number) => {
      state.reduce((prev) => ({ ...prev, count: prev.count + amount }));
    },
    reset: (state) => {
      state.set({ count: 0, name: state.val().name });
    },
    getDisplayText: (state) => {
      const { count, name } = state.val();
      return `${name}: ${count}`;
    },
  },
);

// Direct execution
counter.increment();
counter.incrementBy(5);
console.log(counter.getDisplayText()); // "MyCounter: 6"

// Deferred execution with $ prefix
const incrementBtn = document.querySelector('#increment');
incrementBtn.addEventListener('click', counter.$increment());

const addFiveBtn = document.querySelector('#add-five');
addFiveBtn.addEventListener('click', counter.$incrementBy(5));

Because custom methods take the state object as their first parameter, they have access to all state methods:

Implementation Details

The state module uses a closure-based approach to maintain private state:

Gotchas


Util

Overview

The util module provides utility functions for working with State objects and Conditions in the OEM framework. It offers runtime helpers for filtering, testing, and extracting reactive objects from mixed arrays.

Purpose

Util solves the problem of identifying and working with OEM's special object types (State, Condition) at runtime. Key features include:

Use this module when you need to programmatically work with State or Condition objects, particularly when processing variable argument lists that may contain a mix of different types.

Key Exports

$test()

// Test a static value
const isTrue = $test(true); // () => true === true

// Test a function result
const count = State(5);
const isPositive = $test(() => count.val() > 0, true);

// Use in conditional rendering
const element = h.div(isPositive && h.span('Count is positive'));

// Custom expected value
const isFive = $test(count.val, 5);

extractStates()

const count = State(0);
const name = State('Alice');
const regularValue = 42;
const regularFunc = () => 'hello';

const states = extractStates(count, regularValue, name, regularFunc);
// states = [count, name]

// Use case: Subscribing to all states in a component
states.forEach((state) => {
  state.sub((value) => {
    console.log('State changed:', value);
  });
});

extractConditions()

const isVisible = $test(true);
const isEnabled = $test(() => count.val() > 0);
const regularValue = 42;
const regularFunc = () => 'hello';

const conditions = extractConditions(isVisible, regularValue, isEnabled, regularFunc);
// conditions = [isVisible, isEnabled]

// Use case: Evaluating all conditions
const allTrue = conditions.every((condition) =>
  typeof condition === 'function' ? condition() : condition,
);

Implementation Details

Object Type Detection

Both extractStates and extractConditions use runtime property checking to identify objects:

This approach relies on duck typing rather than instanceof checks, making it flexible but requiring consistent object shapes.

$test Closure Structure

The $test function returns a closure with:

Type Safety Considerations

Trait Library

useScrollIntoViewTrait

Reactively scrolls an element into the visible area of its scrollable ancestor when conditions are met. Supports smooth and instant scrolling via standard ScrollIntoViewOptions.

Signature

useScrollIntoViewTrait(
  el: HTMLElement,
  options?: ScrollIntoViewOptions,
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element to scroll into view
optionsScrollIntoViewOptionsStandard scroll options: behavior ('smooth' or 'instant'), block ('start', 'center', 'end', 'nearest'), inline (same)
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. Scrolls when all Conditions are truthy. Re-evaluates on State changes.

Behavior

  1. Checks all Conditions — if all are truthy, calls el.scrollIntoView(options).
  2. Subscribes to every State in rest so the trait re-evaluates on state changes.
  3. Scrolls on initialization if conditions are met.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

// Scroll when an item becomes active
trait.scrollIntoView(
  { behavior: 'smooth', block: 'center' },
  activeItem.$test((id) => id === itemId),
);

// Scroll to error field on validation failure
trait.scrollIntoView(
  { behavior: 'smooth', block: 'start' },
  form.$test((f) => !!f.errors.email && f.touched.email),
);

// Always scroll into view on mount (no conditions)
trait.scrollIntoView({ behavior: 'instant', block: 'start' });

Common Patterns

Chat auto-scroll

const messages = State<Message[]>([]);

// Scroll the last message into view when messages change
tag.div(
  trait.scrollIntoView(
    { behavior: 'smooth', block: 'end' },
    messages.$test(() => true),
  ),
);

Anchor navigation

const activeSection = State<string>('intro');

// Each section scrolls into view when it becomes active
['intro', 'features', 'pricing'].forEach((id) => {
  tag.section(
    trait.scrollIntoView({ behavior: 'smooth', block: 'start' }, activeSection.$test(id)),
  );
});

Focus first error

const form = useFormState({ name: '', email: '' }, validators);

tag.input(
  trait.scrollIntoView(
    { behavior: 'smooth', block: 'center' },
    form.$test((f) => !!f.errors.name && f.touched.name),
  ),
);

Notes


useAnimationTrait

Reactively plays a Web Animations API keyframe animation on an element. Supports conditional gating, state-driven re-triggering, and automatic cleanup.

Signature

useAnimationTrait(
  el: HTMLElement,
  keyframes:
    | Keyframe[]
    | PropertyIndexedKeyframes
    | (() => Keyframe[] | PropertyIndexedKeyframes),
  options:
    | number
    | KeyframeAnimationOptions
    | (() => number | KeyframeAnimationOptions),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
keyframesKeyframe[] | PropertyIndexedKeyframes | (() => Keyframe[] | PropertyIndexedKeyframes)The animation keyframes. Pass an array of Keyframe objects, a PropertyIndexedKeyframes object, or a function that returns either for reactive evaluation.
optionsnumber | KeyframeAnimationOptions | (() => number | KeyframeAnimationOptions)Animation timing. Pass a duration in ms, a full KeyframeAnimationOptions object, or a function for reactive evaluation.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The animation plays only when all Conditions are truthy.

Behavior

  1. Evaluates keyframes and options (calls them if they are functions).
  2. Checks all Conditions — if all are truthy, plays the animation via el.animate().
  3. If a previous animation from this trait instance is still running, it is cancelled before the new one starts.
  4. Subscribes to every State in rest so the animation re-triggers on state changes.
  5. When a condition is false, the animation is not played but any previously running animation is not cancelled — the trait only gates new plays.
  6. Cleanup cancels any running animation and unsubscribes from all States.

Returns

A cleanup function that cancels the running animation and unsubscribes from all State listeners.

Template Usage

Fade-in on creation

trait.animation([{ opacity: '0' }, { opacity: '1' }], {
  duration: 200,
  easing: 'ease-out',
  fill: 'forwards',
});

Slide-in from below

trait.animation(
  [
    { opacity: '0', transform: 'translateY(8px)' },
    { opacity: '1', transform: 'translateY(0)' },
  ],
  { duration: 250, easing: 'ease-out', fill: 'forwards' },
);

Enter/exit driven by state

const mode = State<'enter' | 'exit'>('enter');

// Enter animation — plays when mode is 'enter'
trait.animation(
  [
    { opacity: '0', transform: 'scale(0.95)' },
    { opacity: '1', transform: 'scale(1)' },
  ],
  { duration: 200, easing: 'ease-out', fill: 'forwards' },
  mode.$test('enter'),
  mode,
);

// Exit animation — plays when mode is 'exit'
trait.animation(
  [
    { opacity: '1', transform: 'scale(1)' },
    { opacity: '0', transform: 'scale(0.95)' },
  ],
  { duration: 150, easing: 'ease-in', fill: 'forwards' },
  mode.$test('exit'),
  mode,
);

Infinite spinner

trait.animation([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
  duration: 800,
  iterations: Infinity,
  easing: 'linear',
});

Reactive duration from state

const speed = State<number>(300);

trait.animation(
  [{ opacity: '0' }, { opacity: '1' }],
  () => ({ duration: speed.val(), easing: 'ease-out', fill: 'forwards' as FillMode }),
  speed,
);

Conditional animation (only when visible)

const visible = State<boolean>(false);

trait.animation(
  [{ opacity: '0' }, { opacity: '1' }],
  { duration: 200, fill: 'forwards' },
  visible.$test(true),
  visible,
);

Attention pulse

trait.animation(
  [{ transform: 'scale(1)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
  { duration: 600, easing: 'ease-in-out', iterations: 2 },
);

Performance Notes

Accessibility

const reducedMotion = useMediaQueryState('(prefers-reduced-motion: reduce)');

trait.animation(
  [{ opacity: '0' }, { opacity: '1' }],
  () => ({ duration: reducedMotion.test(true) ? 0 : 200, fill: 'forwards' as FillMode }),
  reducedMotion,
);

useDataAttributeTrait

Reactively sets or removes data-* attributes on an element using the dataset API. Accepts either bare names (e.g. 'active') or prefixed names (e.g. 'data-active'). When the value is undefined or conditions are falsy, the data attribute is removed.

Signature

useDataAttributeTrait(
  el: HTMLElement,
  name: string,
  val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
namestringThe data attribute name. Can be bare ('active') or prefixed ('data-active'). Automatically converted to the proper dataset key.
val(() => string | number | boolean | undefined) | string | number | boolean | undefinedThe attribute value. Pass a function for reactive evaluation. undefined removes the attribute.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy.

Behavior

  1. Normalizes the name — adds data- prefix if missing, converts to camelCase dataset key.
  2. Evaluates val (calls it if it's a function).
  3. Checks all Conditions — if any are falsy, removes the data attribute.
  4. If all Conditions pass and val is undefined, removes the data attribute.
  5. Otherwise, sets el.dataset[key] = String(val).
  6. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

// Static data attribute
trait.data('id', '42');
// → el.dataset.id = '42' (renders as data-id="42")

// With data- prefix (also works)
trait.data('data-id', '42');
// → same result

// Reactive value
trait.data('active-tab', () => activeTab.val(), activeTab);
// → el.dataset.activeTab = 'home' (renders as data-active-tab="home")

// Conditional data attribute
trait.data(
  'selected',
  'true',
  item.$test((i) => i.selected),
);

// Remove when undefined
trait.data('tooltip', () => tooltipText.val() || undefined, tooltipText);

Common Patterns

Track element state for CSS selectors

// Set data-state for CSS-based styling or external queries
trait.data('state', () => panelState.val(), panelState);
// → [data-state="open"], [data-state="closed"]

Wire up delegation targets

items.forEach((item) =>
  tag.li(
    trait.data('id', item.id),
    trait.event('click', (e) => {
      const id = (e.target as HTMLElement).dataset.id;
      selectItem(id);
    }),
  ),
);

Flag elements for testing

trait.data('testid', 'submit-button');

Comparison with useAttributeTrait

FeatureuseDataAttributeTraituseAttributeTrait
APIUses el.dataset (camelCase keys)Uses el.setAttribute
Name handlingAuto-prefixes data- if missingRequires full attr name
Intended usedata-* attributes onlyAny HTML attribute

Notes


useFocusTrait

Programmatically focuses an HTML element. Unlike most traits, Focus accepts conditions and states as explicit arrays rather than using the rest-parameter extraction pattern.

Signature

useFocusTrait(
  el: HTMLElement,
  conditions?: Condition[],
  states?: StateType<any>[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element to focus
conditionsCondition[]Optional array of Conditions. The element is focused only when all evaluate to truthy. Defaults to [].
statesStateType<any>[]Optional array of State objects to subscribe to. The trait re-evaluates whenever any State publishes. Defaults to [].

Behavior

  1. Checks all Conditions — if all are truthy, calls el.focus().
  2. Subscribes to every State so the trait re-runs on state changes.
  3. Useful for auto-focusing inputs when a modal opens, a route changes, or a condition becomes true.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

trait.focus([modalState.$test((s) => s.open)], [modalState]);
trait.focus([], [routeState]);

useInputEventTrait

Attaches an input-related event listener that extracts the value from e.target.value and passes it to a setter function. Designed for binding form element changes directly to state updates.

Signature

useInputEventTrait(
  el: HTMLElement,
  evt: 'input' | 'change' | 'keyup' | 'keydown' | 'keypress' | 'beforeinput' | 'paste' | 'cut' | 'compositionstart' | 'compositionupdate' | 'compositionend',
  setter: (val: any) => void,
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target form element (typically HTMLInputElement or HTMLTextAreaElement)
evtstringThe input event type. Restricted to: 'input', 'change', 'keyup', 'keydown', 'keypress', 'beforeinput', 'paste', 'cut', 'compositionstart', 'compositionupdate', 'compositionend'.
setter(val: any) => voidA function called with e.target.value when the event fires. Typically a State's set method.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The listener is attached only when all Conditions are truthy.

Behavior

  1. Checks all Conditions — if all are truthy and the listener is not attached, adds the event listener.
  2. When the event fires, calls setter(e.target.value).
  3. If any Condition becomes falsy, removes the listener.
  4. Tracks attachment state to prevent duplicate listeners.
  5. Subscribes to every State so the trait re-evaluates on state changes.

Returns

A cleanup function that removes the event listener and unsubscribes from all State listeners.

Template Usage

trait.inputEvent('input', nameState.set);
trait.inputEvent('change', (val) => filterState.set(val));
trait.inputEvent('keydown', searchState.set, enabled.$test(true));

useTextContentTrait

Sets the text content of an element reactively. Supports single values, arrays of values (concatenated as text nodes), and function getters.

Signature

useTextContentTrait(
  el: HTMLElement,
  text: TextContent | TextContent[] | (() => TextContent | TextContent[]),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Where TextContent = string | number | undefined | unknown.

Parameters

ParameterTypeDescription
elHTMLElementThe target element
textTextContent | TextContent[] | (() => TextContent | TextContent[])The text to display. Can be a single value, an array of values (each appended as a text node), or a function returning either.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy.

Behavior

  1. Clears el.textContent.
  2. Evaluates text (calls it if it's a function).
  3. Checks all Conditions — if any are falsy, the element remains empty.
  4. For arrays: filters out undefined values and appends each as a text node.
  5. For single values: sets el.textContent to the stringified value.
  6. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

trait.textContent('Hello, World!');
trait.textContent(counter.$val);
trait.textContent(() => `${items.val().length} items`, items);

useAttributeTrait

Sets or removes an HTML attribute on an element reactively. When the value is undefined or conditions evaluate to false, the attribute is removed from the element.

Signature

useAttributeTrait(
  el: HTMLElement,
  prop: string,
  val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
propstringThe attribute name (e.g. 'disabled', 'aria-label', 'data-id')
val(() => string | number | boolean | undefined) | string | number | boolean | undefinedThe attribute value. Pass a function for reactive evaluation, or a static value. undefined removes the attribute.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy.

Behavior

  1. Evaluates val (calls it if it's a function).
  2. Checks all Conditions — if any are falsy, removes the attribute.
  3. If all Conditions pass and val is undefined, removes the attribute.
  4. Otherwise, sets the attribute via el.setAttribute(prop, String(val)).
  5. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

When used through a Template's trait proxy, the el parameter is supplied automatically:

trait.attribute(
  'disabled',
  'true',
  formState.$test((s) => !s.isValid),
);
trait.attribute('aria-label', 'Close dialog');
trait.attribute(
  'data-active',
  'true',
  tabState.$test((s) => s.active === id),
  visible.$test(true),
);

useStyleTrait

Reactively sets a single CSS style property on an element. Supports both standard CSSStyleDeclaration properties and CSS custom properties (--*).

Signature

useStyleTrait(
  el: HTMLElement,
  prop: keyof CSSStyleDeclaration | `--${string}`,
  val: (() => string | number | undefined) | (string | number | undefined),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
propkeyof CSSStyleDeclaration | \--${string}``The CSS property name. Use camelCase for standard properties (e.g. 'backgroundColor') or '--custom-prop' for custom properties.
val(() => string | number | undefined) | string | number | undefinedThe CSS value. Pass a function for reactive evaluation.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The style is applied only when all Conditions are truthy.

Behavior

  1. Evaluates val (calls it if it's a function).
  2. Checks all Conditions — if all are truthy, applies the style.
  3. For custom properties (--*): uses el.style.setProperty(prop, val).
  4. For standard properties: assigns directly to el.style[prop].
  5. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

// Token value (re-evaluates on theme change via $val)
trait.style('backgroundColor', surface_bg_primary.$val);
trait.style('padding', space_padding_md.$val);

// Conditional toggle — unconditional default + conditional override
// IMPORTANT: Never use two opposing $test conditions on the same property.
// Each conditional trait has its own saved-value slot, and opposing conditions
// will corrupt each other's snapshots, preventing the style from reverting.
trait.style('opacity', '0'); // default
trait.style('opacity', '1', visible.$test(true)); // override when true

// Custom CSS property
trait.style('--header-height', '64px');

// Conditional application
trait.style(
  'display',
  'none',
  someState.$test((s) => !s.visible),
);

useInputValueTrait

Reactively sets the value property of an <input> or <textarea> element. Pairs with useInputEventTrait for two-way data binding.

Signature

useInputValueTrait(
  el: HTMLInputElement | HTMLTextAreaElement,
  value: (() => string | number | undefined) | (string | number | undefined),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLInputElement | HTMLTextAreaElementThe target input or textarea element
value(() => string | number | undefined) | string | number | undefinedThe value to set. Pass a function for reactive evaluation.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy.

Behavior

  1. Evaluates value (calls it if it's a function).
  2. Checks all Conditions — if all are truthy, sets el.value.
  3. Subscribes to every State in rest so the trait re-runs on state changes.
  4. Typically used alongside useInputEventTrait to create a reactive loop: state → input value → user types → event → update state → input value updates.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

// Two-way binding pattern
tag.input(trait.inputValue(nameState.$val), trait.inputEvent('input', nameState.set));

// Conditional binding
tag.input(trait.inputValue(searchState.$val, isEditing.$test(true)));

useEventTrait

Attaches a DOM event listener to an element. The listener is added or removed reactively based on Conditions and State changes.

Signature

useEventTrait(
  el: HTMLElement,
  evt: keyof GlobalEventHandlersEventMap,
  cb: (evt?: GlobalEventHandlersEventMap[keyof GlobalEventHandlersEventMap]) => void,
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
evtkeyof GlobalEventHandlersEventMapThe event name (e.g. 'click', 'mouseenter', 'submit')
cb(evt?) => voidThe event handler callback
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The listener is attached only when all Conditions are truthy and detached when any becomes falsy.

Behavior

  1. Checks all Conditions — if all are truthy and the listener is not already attached, calls el.addEventListener(evt, cb).
  2. If any Condition becomes falsy, calls el.removeEventListener(evt, cb).
  3. Tracks attachment state internally to prevent duplicate listeners.
  4. Subscribes to every State in rest so the trait re-evaluates on state changes.

Returns

A cleanup function that removes the event listener and unsubscribes from all State listeners.

Template Usage

trait.event(
  'click',
  count.$reduce((prev) => prev + 1),
);
trait.event('submit', (e) => {
  e.preventDefault();
  save();
});
trait.event('mouseenter', showTooltip, enabled.$test(true));

useInnerHTMLTrait

Sets the inner content of an element by appending child elements or text nodes. Clears the element first, then appends children — making it safe for HTMLElement and SVGElement children (no serialization).

Signature

useInnerHTMLTrait(
  el: HTMLElement,
  children: Child | Child[] | (() => Child | Child[]),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Where Child = string | number | HTMLElement | SVGElement | undefined | unknown.

Parameters

ParameterTypeDescription
elHTMLElementThe parent element whose content will be set
childrenChild | Child[] | (() => Child | Child[])The child content. Can be a single value, an array, or a function returning either. HTMLElement/SVGElement children are appended directly; primitives are converted to text nodes.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy.

Behavior

  1. Clears el.innerHTML.
  2. Evaluates children (calls it if it's a function).
  3. Checks all Conditions — if any are falsy, the element remains empty.
  4. For arrays: filters out falsy values, appends HTMLElement/SVGElement children via appendChild, and wraps primitives in text nodes.
  5. For single HTMLElement/SVGElement: appends directly.
  6. For single primitives: sets el.innerHTML to the stringified value.
  7. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

tag.div(
  trait.innerHTML(
    () => [tag.h1(trait.textContent('Hello')), tag.p(trait.textContent(message.$val))],
    message,
  ),
);

tag.ul(
  trait.innerHTML(() => items.val().map((item) => tag.li(trait.textContent(item.label))), items),
);

useAriaTrait

Reactively sets ARIA attributes and the role attribute on an element. When the value is undefined or conditions evaluate to false, the attribute is removed. Provides first-class accessibility support with typed ARIA property names.

Signature

useAriaTrait(
  el: HTMLElement,
  prop: 'role' | `aria-${string}`,
  val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
prop'role' | \aria-${string}``The ARIA attribute name (e.g. 'aria-label', 'aria-expanded', 'role')
val(() => string | number | boolean | undefined) | string | number | boolean | undefinedThe attribute value. Pass a function for reactive evaluation. undefined removes the attribute.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy.

Behavior

  1. Evaluates val (calls it if it's a function).
  2. Checks all Conditions — if any are falsy, removes the attribute.
  3. If all Conditions pass and val is undefined, removes the attribute.
  4. Otherwise, sets the attribute via el.setAttribute(prop, String(val)).
  5. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

// Static ARIA label
trait.aria('aria-label', 'Close dialog');

// Role assignment
trait.aria('role', 'navigation');

// Reactive expanded state
trait.aria(
  'aria-expanded',
  () => String(menuOpen.val()),
  menuOpen,
);

// Conditional aria-hidden
trait.aria('aria-hidden', 'true', visible.$test(false));
trait.aria('aria-hidden', 'false', visible.$test(true));

// Reactive aria-live region
trait.aria('aria-live', 'polite');
trait.aria(
  'aria-label',
  () => `${notifications.val().length} new notifications`,
  notifications,
);

// Aria-current for navigation
trait.aria(
  'aria-current',
  'page',
  activeRoute.$test((r) => r === '/about'),
);

Common ARIA Patterns

Accessible toggle button

const expanded = State<boolean>(false);

tag.button(
  trait.aria('aria-expanded', () => String(expanded.val()), expanded),
  trait.aria('aria-controls', 'panel-1'),
  trait.event('click', expanded.$reduce((v) => !v)),
  trait.textContent(() => (expanded.val() ? 'Collapse' : 'Expand'), expanded),
);

Live region for status updates

tag.div(
  trait.aria('role', 'status'),
  trait.aria('aria-live', 'polite'),
  trait.textContent(() => statusMessage.val(), statusMessage),
);

Tab panel

tag.div(
  trait.aria('role', 'tabpanel'),
  trait.aria('aria-labelledby', 'tab-1'),
  trait.aria(
    'aria-hidden',
    () => String(activeTab.val() !== 'tab-1'),
    activeTab,
  ),
);

Comparison with useAttributeTrait

FeatureuseAriaTraituseAttributeTrait
Type safetyTyped to role and aria-*Accepts any string
Intended useAccessibility attributesGeneral HTML attributes
BehaviorIdenticalIdentical

useAriaTrait is a semantic specialization — it constrains the prop parameter to valid ARIA attributes, making intent clearer and preventing accidental misuse.

Notes


useClassNameTrait

Sets the class attribute of an HTML element reactively. Replaces the entire class string — does not toggle individual classes.

Signature

useClassNameTrait(
  el: HTMLElement,
  className: string | (() => string),
  ...rest: (StateType<any> | Condition)[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
classNamestring | (() => string)The class string to apply. Pass a function for reactive evaluation.
...rest(StateType<any> | Condition)[]Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy.

Behavior

  1. Evaluates className (calls it if it's a function).
  2. Checks all Conditions — if all are truthy, sets the class attribute via el.setAttribute('class', ...).
  3. If any Condition is falsy, the class is not applied (the previous value remains).
  4. Subscribes to every State in rest so the trait re-runs on state changes.

Returns

A cleanup function that unsubscribes from all State listeners.

Template Usage

trait.className('btn btn-primary');
trait.className('tab active', isActive.$test(true));
trait.className('tab', isActive.$test(false));

useStyleOnEventTrait

Applies a CSS style property when a DOM event fires on the element. Unlike useStyleTrait, this trait does not subscribe to State objects — it runs the style application inside the event handler itself.

Signature

useStyleOnEventTrait(
  el: HTMLElement,
  evt: keyof HTMLElementEventMap,
  prop: keyof CSSStyleDeclaration | `--${string}`,
  val: (() => string | number | undefined) | (string | number | undefined),
  ...rest: Condition[]
) => () => void

Parameters

ParameterTypeDescription
elHTMLElementThe target element
evtkeyof HTMLElementEventMapThe event that triggers the style application (e.g. 'mouseenter', 'mouseleave', 'focus')
propkeyof CSSStyleDeclaration | \--${string}``The CSS property name (camelCase or --custom-prop)
val(() => string | number | undefined) | string | number | undefinedThe CSS value. Pass a function for reactive evaluation at event time.
...restCondition[]Optional Conditions. The style is applied only when all Conditions are truthy at the time the event fires.

Behavior

  1. Attaches an event listener for evt on el.
  2. When the event fires: evaluates val, checks Conditions, and applies the style if all pass.
  3. For custom properties (--*): uses el.style.setProperty(prop, val).
  4. For standard properties: assigns directly to el.style[prop].
  5. Does not subscribe to State objects — only re-evaluates when the event fires.

Returns

A cleanup function that removes the event listener.

Template Usage

// Hover highlight pattern
trait.styleOnEvent('mouseenter', 'backgroundColor', action_bg_hover.$val);
trait.styleOnEvent('mouseleave', 'backgroundColor', action_bg_primary.$val);

// Focus ring
trait.styleOnEvent('focus', 'outline', () => `2px solid ${focus_border_primary.val()}`);
trait.styleOnEvent('blur', 'outline', 'none');

State Library

UrlState

A reactive state hook that tracks the current URL, matching it against a set of defined routes and extracting params, query strings, and hash fragments.

Features

Usage

import { useUrlState } from '@linttrap/oem';

const url = useUrlState<{
  '/': any;
  '/about': any;
  '/user/:id': any;
  '/user/:id/post/:postId': any;
}>({
  '/': () => '/',
  '/about': () => '/about',
  '/user/:id': ({ id }) => `/user/${id}`,
  '/user/:id/post/:postId': ({ id, postId }) => `/user/${id}/post/${postId}`,
});

// Read the current URL state
const { matchedRoute, params, query, hash } = url.val();

// Subscribe to URL changes
url.sub(({ matchedRoute, params, query, hash }) => {
  console.log('Route:', matchedRoute);
  console.log('Params:', params);
  console.log('Query:', query);
  console.log('Hash:', hash);
});

Signature

function useUrlState<T extends Record<string, any>>(routes: {
  [K in keyof T]: K extends string ? RouteHandler<K> : never;
}): StateType<UrlState<typeof routes>, {}>;

Parameters

ParameterTypeDescription
routesobjectA map of route patterns to handler functions. Patterns use :param syntax for dynamic segments.

Route Handler Signatures

Route handlers are type-inferred from the pattern string:

Return Value

Returns a State<UrlState> containing:

PropertyTypeDescription
matchedRoutestringThe route pattern that matched the current pathname, or ''
paramsRecord<string, string>Named parameters extracted from the matched route
queryRecord<string, string>Parsed query string parameters
hashstringThe hash fragment (without the leading #)
locationLocationThe raw document.location object
routesRoutesThe routes map passed to the hook

Behavior

Common Patterns

Route-based rendering

const url = useUrlState<{
  '/': any;
  '/about': any;
  '/contact': any;
}>({
  '/': () => '/',
  '/about': () => '/about',
  '/contact': () => '/contact',
});

url.sub(({ matchedRoute }) => {
  switch (matchedRoute) {
    case '/':
      renderHome();
      break;
    case '/about':
      renderAbout();
      break;
    case '/contact':
      renderContact();
      break;
  }
});

Reading dynamic params

const url = useUrlState<{
  '/user/:id': any;
}>({
  '/user/:id': ({ id }) => `/user/${id}`,
});

// If current URL is /user/42
const { params } = url.val();
console.log(params.id); // "42"

Using with conditions

const url = useUrlState<{
  '/': any;
  '/about': any;
}>({
  '/': () => '/',
  '/about': () => '/about',
});

// Create a condition to test the matched route
const isHome = url.$test((s) => s.matchedRoute === '/');
const isAbout = url.$test((s) => s.matchedRoute === '/about');

Notes


MediaQueryState

A reactive state hook that tracks whether the current viewport matches specified media query conditions.

Features

Usage

import { useMediaQueryState } from '@linttrap/oem';

// Track mobile viewport (max 768px)
const isMobile = useMediaQueryState({
  maxWidth: 768,
});

// Track tablet viewport (768px - 1024px)
const isTablet = useMediaQueryState({
  minWidth: 768,
  maxWidth: 1024,
});

// Track desktop viewport (min 1024px)
const isDesktop = useMediaQueryState({
  minWidth: 1024,
});

// Track print media
const isPrint = useMediaQueryState({
  type: 'print',
});

Props

PropertyTypeDefaultDescription
type'screen' | 'print' | 'all''all'Media type to match against
minWidthnumber0Minimum viewport width in pixels
maxWidthnumberInfinityMaximum viewport width in pixels

Return Value

Returns a State<boolean> that is true when the media query matches and false otherwise.

Behavior

The state automatically:

Common Patterns

Responsive Breakpoints

// Define standard breakpoints
const isMobile = useMediaQueryState({ maxWidth: 639 });
const isTablet = useMediaQueryState({ minWidth: 640, maxWidth: 1023 });
const isDesktop = useMediaQueryState({ minWidth: 1024 });

Conditional Rendering

const isMobile = useMediaQueryState({ maxWidth: 768 });

if (isMobile.val()) {
  // Render mobile layout
} else {
  // Render desktop layout
}

Notes


ThemeState

A small state hook for managing the current theme as a reactive State value.

Features

Usage

import { useThemeState } from '@linttrap/oem';

const theme = useThemeState('light');

// Read the current theme
const current = theme.val();

// Update the theme
theme.set('dark');

Signature

type Theme = 'light' | 'dark';

function useThemeState(theme: Theme): State<Theme>;

Parameters

ParameterTypeDefaultDescription
themeThemeInitial theme value ('light' or 'dark')

Return Value

Returns a State<Theme> representing the current theme.

Behavior

Notes


OnlineState

A reactive state hook that tracks whether the browser is currently online or offline.

Features

Usage

import { useOnlineState } from '@linttrap/oem';

const isOnline = useOnlineState();

// Read current connectivity
console.log(isOnline.val()); // true or false

// React to connectivity changes
isOnline.sub((online) => {
  console.log(online ? 'Back online' : 'Gone offline');
});

Signature

function useOnlineState(): StateType<boolean, {}>;

Parameters

None.

Return Value

Returns a State<boolean> that is true when the browser is online and false when offline.

Behavior

Common Patterns

Conditional UI based on connectivity

const isOnline = useOnlineState();

// Show/hide an offline banner
trait.style('display', 'none', isOnline.$test(true));
trait.style('display', 'flex', isOnline.$test(false));

Gate network actions

const isOnline = useOnlineState();

trait.event(
  'click',
  () => fetchData(),
  isOnline.$test(true),
);

Notes


WindowSizeState

A reactive state hook that tracks the current viewport dimensions.

Features

Usage

import { useWindowSizeState } from '@linttrap/oem';

const windowSize = useWindowSizeState();

// Read current dimensions
const { width, height } = windowSize.val();

// Subscribe to size changes
windowSize.sub(({ width, height }) => {
  console.log(`${width}x${height}`);
});

Signature

function useWindowSizeState(): StateType<WindowSize, {}>;

Parameters

None.

Return Value

Returns a State<WindowSize> containing:

PropertyTypeDescription
widthnumberCurrent viewport width in pixels
heightnumberCurrent viewport height in pixels

Behavior

Common Patterns

Dynamic canvas sizing

const windowSize = useWindowSizeState();

windowSize.sub(({ width, height }) => {
  canvas.width = width;
  canvas.height = height;
});

Computed column count

const windowSize = useWindowSizeState();

trait.style('gridTemplateColumns', () => {
  const cols = Math.floor(windowSize.val().width / 300);
  return `repeat(${cols}, 1fr)`;
}, windowSize);

Reactive dimension values

const windowSize = useWindowSizeState();

trait.style('height', () => `${windowSize.val().height - 64}px`, windowSize);

Comparison with useMediaQueryState

FeatureuseWindowSizeStateuseMediaQueryState
Return type{ width, height }boolean
Use caseComputed layoutsBreakpoint conditions
Condition-friendlyRequires $test fnDirect $test(true/false)

Use useMediaQueryState for breakpoint-gated trait conditions. Use useWindowSizeState when you need raw pixel values for calculations.

Notes


TokenState

A derived state hook that selects between two token values based on the current theme.

Features

Usage

import { useThemeState, useTokenState } from '@linttrap/oem';

const theme = useThemeState('light');
const primaryColor = useTokenState('#111827', '#f9fafb', theme);

// Read the current token value
const color = primaryColor.val();

// Switch theme and let token update
theme.set('dark');

Signature

function useTokenState<T>(lightVal: T, darkVal: T, themeState: StateType<Theme, {}>): State<T>;

Parameters

ParameterTypeDefaultDescription
lightValTToken value for the light theme
darkValTToken value for the dark theme
themeStateStateType<Theme, {}>Theme state used to select the active token

Return Value

Returns a State<T> whose value updates when the theme changes.

Behavior

Common Patterns

const theme = useThemeState('light');
const textColor = useTokenState('#111', '#eee', theme);
const bgColor = useTokenState('#fff', '#0b0f1a', theme);

Notes


IntersectionObserverState

A reactive state hook that tracks whether an element is visible within the viewport (or a specified root element) using the Intersection Observer API.

Features

Usage

import { useIntersectionObserverState } from '@linttrap/oem';

const section = document.getElementById('hero')!;
const visibility = useIntersectionObserverState(section);

// Check if element is visible
visibility.sub(({ isIntersecting }) => {
  console.log(isIntersecting ? 'Visible' : 'Hidden');
});

Signature

function useIntersectionObserverState(
  el: Element,
  options?: IntersectionObserverInit,
): StateType<IntersectionObserverStateValue, {}>;

Parameters

ParameterTypeDefaultDescription
elElementThe DOM element to observe
optionsIntersectionObserverInitStandard IntersectionObserver options (root, rootMargin, threshold)

Return Value

Returns a State<IntersectionObserverStateValue> with the following shape:

PropertyTypeDescription
isIntersectingbooleanWhether the element is currently intersecting
intersectionRationumberFraction of the element visible (0 to 1)
boundingClientRectDOMRectReadOnly | nullThe element's bounding rect at observation time

Behavior

Common Patterns

Lazy loading content

const placeholder = document.getElementById('lazy-section')!;
const visibility = useIntersectionObserverState(placeholder, {
  threshold: 0.1,
});

visibility.sub(({ isIntersecting }) => {
  if (isIntersecting) loadContent();
});

Scroll-spy navigation

const sections = ['intro', 'features', 'pricing'].map((id) => ({
  id,
  visibility: useIntersectionObserverState(document.getElementById(id)!, {
    threshold: 0.5,
  }),
}));

sections.forEach(({ id, visibility }) => {
  visibility.sub(({ isIntersecting }) => {
    if (isIntersecting) activeSection.set(id);
  });
});

Animate on scroll

const el = document.getElementById('animate-me')!;
const visibility = useIntersectionObserverState(el, { threshold: 0.2 });

trait.style('opacity', '0');
trait.style(
  'opacity',
  '1',
  visibility.$test((v) => v.isIntersecting),
);
trait.style(
  'transition',
  'opacity 0.3s ease',
);

Notes


TimerState

A reactive state hook that increments a counter at a fixed interval. Provides custom methods to start, stop, and reset the timer.

Features

Usage

import { useTimerState } from '@linttrap/oem';

// Tick every second, auto-starts
const timer = useTimerState(1000);

// Read current tick count
console.log(timer.val()); // 0, 1, 2, ...

// Control the timer
timer.stop();
timer.reset();
timer.start();

// Subscribe to ticks
timer.sub((count) => {
  console.log('Tick:', count);
});

Signature

function useTimerState(
  intervalMs: number,
  options?: { autoStart?: boolean },
): StateType<number, { start; stop; reset }>;

Parameters

ParameterTypeDefaultDescription
intervalMsnumberInterval between ticks in milliseconds
options{ autoStart?: boolean }Configuration. autoStart defaults to true if omitted.

Return Value

Returns a State<number> with these additional custom methods:

MethodSignatureDescription
start()() => voidStarts the interval (no-op if already running)
stop()() => voidStops the interval
reset()() => voidStops the interval and resets the counter to 0

Each method also has a $-prefixed deferred version ($start, $stop, $reset) that returns () => void.

Behavior

Common Patterns

Auto-save indicator

const timer = useTimerState(30000); // every 30 seconds

timer.sub(() => {
  saveDocument();
});

Countdown display

const DURATION = 60;
const timer = useTimerState(1000);

trait.textContent(() => `${DURATION - timer.val()}s remaining`);

Polling with deferred controls

const timer = useTimerState(5000, { autoStart: false });

timer.sub(() => fetchUpdates());

// Wire start/stop to buttons
trait.event('click', timer.$start());
trait.event('click', timer.$stop());

Session timeout

const idle = useTimerState(1000);

idle.sub((seconds) => {
  if (seconds >= 300) logout();
});

// Reset on user activity
document.addEventListener('mousemove', () => idle.reset());

Notes


FormState

A reactive state hook for managing form values, validation errors, touched fields, and dirty state.

Features

Usage

import { useFormState } from '@linttrap/oem';

const form = useFormState(
  { email: '', password: '' },
  {
    email: (val) => (!val ? 'Required' : undefined),
    password: (val) => (val.length < 8 ? 'Min 8 characters' : undefined),
  },
);

// Set a field value
form.setField('email', 'user@example.com');

// Touch a field
form.touch('email');

// Validate all fields
const isValid = form.validate();

// Read state
const { values, errors, touched, dirty, valid } = form.val();

// Reset to initial
form.reset();

Signature

function useFormState<T extends Record<string, any>>(
  initialValues: T,
  validators?: Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>,
): StateType<FormStateValue<T>, { setField; setError; touch; validate; reset }>;

Parameters

ParameterTypeDefaultDescription
initialValuesTThe initial form values
validatorsPartial<Record<keyof T, (value, values) => string | undefined>>Optional validators per field. Return a string for errors, undefined for valid.

Return Value

Returns a State<FormStateValue<T>> with shape:

PropertyTypeDescription
valuesTCurrent field values
errorsPartial<Record<keyof T, string>>Validation error messages per field
touchedPartial<Record<keyof T, boolean>>Whether each field has been touched
dirtybooleantrue if any field has been modified
validbooleantrue if there are no validation errors

Custom Methods

MethodSignatureDescription
setField(field, value)(keyof T, T[keyof T]) => voidUpdates a field value and re-runs validation
setError(field, error)(keyof T, string | undefined) => voidManually set or clear an error on a field
touch(field)(keyof T) => voidMarks a field as touched
validate()() => booleanValidates all fields, touches all, returns validity
reset()() => voidResets values, errors, touched, dirty, and valid

Each method also has a $-prefixed deferred version.

Behavior

Common Patterns

Contact form

const form = useFormState(
  { name: '', email: '', message: '' },
  {
    name: (v) => (!v ? 'Name is required' : undefined),
    email: (v) => (!v.includes('@') ? 'Invalid email' : undefined),
    message: (v) => (v.length < 10 ? 'Too short' : undefined),
  },
);

Two-way binding with input traits

trait.inputValue(() => form.val().values.email, form);
trait.inputEvent('input', (val) => form.setField('email', val), form);
trait.event('blur', form.$touch('email'));

Show errors only for touched fields

trait.textContent(
  () => {
    const { errors, touched } = form.val();
    return touched.email ? errors.email || '' : '';
  },
  form,
);

Submit handler

trait.event('click', () => {
  if (form.validate()) {
    submitForm(form.val().values);
  }
});

Cross-field validation

const form = useFormState(
  { password: '', confirmPassword: '' },
  {
    confirmPassword: (val, values) =>
      val !== values.password ? 'Passwords must match' : undefined,
  },
);

Notes

Examples

Theme Toggle Example

import { useThemeState, useTokenState } from '@linttrap/oem';

const theme = useThemeState('light');
const bg = useTokenState('#c7c7c7', '#222222', theme);
const fg = useTokenState('#222222', '#c7c7c7', theme);
const accent = useTokenState('#555555', '#999999', theme);

const app = tag.div(
  trait.style('backgroundColor', bg.$val),
  trait.style('color', fg.$val),
  trait.style('transition', 'all 0.3s ease'),
  tag.button(
    trait.text('☀️ Light', theme.$test('dark')),
    trait.text('🌙 Dark', theme.$test('light')),
    trait.event('click', () => theme.reduce((t) => (t === 'dark' ? 'light' : 'dark'))),
    trait.style('color', accent.$val),
  ),
);

Dynamic List Example

const items = State<string[]>([]);
const input = State('');

const list = tag.div(
  tag.div(
    trait.style('display', 'flex'),
    trait.style('gap', '8px'),
    tag.input(
      trait.inputValue(input.$val),
      trait.inputEvent('input', input.$set),
      trait.style('flex', '1'),
    ),
    tag.button(
      trait.text('Add'),
      trait.event('click', () => {
        items.reduce((prev) => [...prev, input.val()]);
        input.set('');
      }),
    ),
  ),
  tag.ul(trait.innerHTML(() => items.val().map((item) => tag.li(trait.text(item))), items)),
);

Counter Example

const count = State(0);

const counter = tag.div(
  trait.style('display', 'flex'),
  trait.style('alignItems', 'center'),
  trait.style('gap', '16px'),
  tag.button(
    trait.text('−'),
    trait.event(
      'click',
      count.$reduce((n) => n - 1),
    ),
    trait.style('fontSize', '24px'),
  ),
  tag.span(
    trait.text(count.$val),
    trait.style('fontSize', '48px'),
    trait.style('fontWeight', '700'),
    trait.style('color', '#555555'),
  ),
  tag.button(
    trait.text('+'),
    trait.event(
      'click',
      count.$reduce((n) => n + 1),
    ),
    trait.style('fontSize', '24px'),
  ),
);

Conditional Styling Example

const isActive = State(false);

tag.div(
  // Base styles — always applied
  trait.style('padding', '16px'),
  trait.style('borderRadius', '8px'),
  trait.style('cursor', 'pointer'),
  trait.style('transition', 'all 0.2s ease'),

  // Conditional branches — never ternaries
  trait.style('backgroundColor', '#555555', isActive.$test(true)),
  trait.style('backgroundColor', '#c7c7c7', isActive.$test(false)),
  trait.style('color', '#c7c7c7', isActive.$test(true)),
  trait.style('color', '#888888', isActive.$test(false)),
  trait.style('boxShadow', '0 0 20px rgba(80,80,80,0.3)', isActive.$test(true)),
  trait.style('boxShadow', 'none', isActive.$test(false)), isActive.$test(false)),
  trait.text('Active', isActive.$test(true)),
  trait.text('Inactive', isActive.$test(false)),
  trait.event('click', isActive.$reduce(v => !v)),
);