npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@effex/form

v1.0.1

Published

Form handling for Effex applications

Readme

@effex/form

Type-safe form handling for Effex applications with Effect Schema validation.

Installation

pnpm add @effex/form effect

@effex/dom re-exports @effex/core, so you don't need to install core separately.

Overview

Forms in @effex/form are defined separately from their runtime state. This separation allows you to:

  • Define forms at module level (like routers)
  • Reuse form definitions across components
  • Get full type inference for field access
  • Use context-based field access via yield*

Basic Usage

import { Effect, Schema } from "effect";
import { $, collect, Readable, when } from "@effex/dom";
import { Field, Form } from "@effex/form";

// 1. Define the form at module level
const LoginForm = Form.make({
  email: Field.make(
    Schema.String.pipe(Schema.nonEmptyString({ message: () => "Email is required" })),
    { validateOn: "blur" },
  ),
  password: Field.make(
    Schema.String.pipe(Schema.minLength(8, { message: () => "At least 8 characters" })),
    { validateOn: "blur" },
  ),
});

// 2. Create field components — each yields only the state it needs
const EmailField = () =>
  Effect.gen(function* () {
    const field = yield* LoginForm.fields.email;
    const hasError = Readable.map(field.errors, (e) => e.length > 0);

    return yield* $.div(
      {},
      collect(
        $.label({}, $.of("Email")),
        $.input({
          type: "email",
          value: field.value,
          onInput: (e) => field.set((e.target as HTMLInputElement).value),
          onBlur: () => field.blur(),
        }),
        when(hasError, {
          onTrue: () => $.span({ class: "error" }, $.of("Invalid email")),
          onFalse: () => $.span(),
        }),
      ),
    );
  });

const PasswordField = () =>
  Effect.gen(function* () {
    const field = yield* LoginForm.fields.password;
    const hasError = Readable.map(field.errors, (e) => e.length > 0);

    return yield* $.div(
      {},
      collect(
        $.label({}, $.of("Password")),
        $.input({
          type: "password",
          value: field.value,
          onInput: (e) => field.set((e.target as HTMLInputElement).value),
          onBlur: () => field.blur(),
        }),
        when(hasError, {
          onTrue: () => $.span({ class: "error" }, $.of("Too short")),
          onFalse: () => $.span(),
        }),
      ),
    );
  });

const SubmitButton = () =>
  Effect.gen(function* () {
    const form = yield* LoginForm.form;

    return yield* $.button(
      {
        type: "submit",
        disabled: form.isSubmitting,
      },
      $.of(Readable.map(form.isSubmitting, (s) => (s ? "Submitting..." : "Log In"))),
    );
  });

// 3. Compose the form — $.form automatically receives onSubmit from Form.provide
const LoginPage = () =>
  LoginForm.provide(
    {
      defaults: { email: "", password: "" },
      onSubmit: (ctx) =>
        Effect.tryPromise(() =>
          fetch("/api/login", {
            method: "POST",
            body: JSON.stringify(ctx.decoded),
          }),
        ),
    },
    $.form(
      { class: "login-form" },
      collect(EmailField(), PasswordField(), SubmitButton()),
    ),
  );

Note: Form.provide automatically injects an onSubmit handler (with preventDefault) into the first element via context. Just make sure $.form is your first element.

Field Definition

Fields wrap Effect Schemas with UI configuration:

import { Field } from "@effex/form";
import { Schema } from "effect";

// Basic field
const nameField = Field.make(Schema.String);

// Field with validation config
const emailField = Field.make(
  Schema.String.pipe(Schema.nonEmptyString()),
  { validateOn: "blur", debounce: 300 },
);

// Nested struct field
const addressField = Field.make({
  street: Field.make(Schema.String),
  city: Field.make(Schema.String),
  zip: Field.make(Schema.String),
});

// Array field
const tagsField = Field.Array(Field.make(Schema.String));

// Map field (dynamic key-value pairs)
const metadataField = Field.Map(Schema.String, Field.make(Schema.String));

Validation Timing

Control when validation runs per-field or form-wide:

// Per-field config (overrides form default)
Field.make(Schema.String, { validateOn: "change" });

// Form-wide default
Form.make(
  { name: Field.make(Schema.String) },
  { validateOn: "blur", debounce: 200 },
);

Options:

  • "blur" — Validate when field loses focus (default)
  • "change" — Validate on every change (respects debounce)
  • "submit" — Only validate on form submission

Field State

Each field component yields only the state it needs. This keeps components focused and makes the data flow clear:

const NameField = () =>
  Effect.gen(function* () {
    const field = yield* MyForm.fields.name;

    return yield* $.input({
      value: field.value,
      onInput: (e) => field.set((e.target as HTMLInputElement).value),
      onBlur: () => field.blur(),
    });
  });

Leaf Field State

const field = yield* MyForm.fields.name;

// Reactive values
field.value    // Signal<T> — current value
field.errors   // Readable<ParseIssue[]> — validation errors
field.touched  // Readable<boolean> — has been blurred
field.dirty    // Readable<boolean> — changed from initial

// Actions (all return Effect<void>)
yield* field.set("new value");
yield* field.update((v) => v.toUpperCase());
yield* field.blur();   // Mark as touched, triggers validation
yield* field.focus();  // Mark as focused
yield* field.reset();  // Reset to initial value

Struct Field State

Struct fields provide access to nested field states:

const address = yield* MyForm.fields.address;

// Aggregate reactive values
address.value    // Signal<{ street, city, zip }>
address.errors   // Readable<ParseIssue[]> — aggregated from nested fields
address.touched  // Readable<boolean> — true if any nested field touched
address.dirty    // Readable<boolean> — true if any nested field changed

// Access nested states
address.fields.street  // LeafFieldState<string>
address.fields.city    // LeafFieldState<string>

// Actions
yield* address.set({ street: "123 Main", city: "NYC", zip: "10001" });
yield* address.reset();

Array Field State

Array fields provide collection manipulation and per-item state:

const tags = yield* MyForm.fields.tags;

// Reactive values
tags.value    // Signal<readonly string[]>
tags.length   // Readable<number>
tags.items    // Readable<readonly ItemState[]> — per-item field states
tags.errors   // Readable<ParseIssue[]> — aggregated
tags.touched  // Readable<boolean> — aggregated
tags.dirty    // Readable<boolean> — aggregated

// Mutations (all return Effect<void>)
yield* tags.push("new-tag");
yield* tags.pop();
yield* tags.unshift("first");
yield* tags.shift();
yield* tags.insertAt(2, "middle");
yield* tags.removeAt(1);
yield* tags.move(0, 3);         // Move item from index 0 to index 3
yield* tags.clear();
yield* tags.reset();

Map Field State

Map fields provide dynamic key-value pairs:

const metadata = yield* MyForm.fields.metadata;

// Reactive values
metadata.value    // Readable<ReadonlyMap<string, string>>
metadata.size     // Readable<number>
metadata.entries  // Readable<ReadonlyMap<string, EntryState>>

// Mutations
yield* metadata.setEntry("color", "blue");
const entry = yield* metadata.getEntry("color");  // EntryState | undefined
yield* metadata.delete("color");
yield* metadata.clear();
yield* metadata.reset();

Form State

Components that need form-level state (like submit buttons or status indicators) yield form separately:

const SubmitButton = () =>
  Effect.gen(function* () {
    const form = yield* MyForm.form;

    return yield* $.button(
      { type: "submit", disabled: form.isSubmitting },
      $.of(Readable.map(form.isSubmitting, (s) => (s ? "Saving..." : "Save"))),
    );
  });

const FormStatus = () =>
  Effect.gen(function* () {
    const form = yield* MyForm.form;

    return yield* when(form.isDirty, {
      onTrue: () => $.span({}, $.of("You have unsaved changes")),
      onFalse: () => $.span(),
    });
  });

Form State Properties

const form = yield* MyForm.form;

// Reactive values
form.isValid       // Readable<boolean> — all fields valid
form.isSubmitting  // Readable<boolean> — submit in progress
form.isTouched     // Readable<boolean> — any field touched
form.isDirty       // Readable<boolean> — any field changed
form.errors        // Readable<ParseIssue[]> — form-level errors

// Actions
yield* form.validate();   // Validate all fields, returns boolean
yield* form.reset();      // Reset all fields to initial values
yield* form.submit();     // Validate then call onSubmit handlers

// Get values
const encoded = yield* form.getEncoded();  // Raw form values
const decoded = yield* form.getDecoded();  // Validated values (may fail with ParseError)

Submit Handlers

Submit handlers can be defined at two levels — form-level and instance-level. Both run on successful validation; form-level runs first.

// Form-level handler (runs first — good for analytics, logging)
const MyForm = Form.make(
  { name: Field.make(Schema.String) },
  {
    onSubmit: (ctx) =>
      Effect.sync(() => {
        analytics.track("form_submit", ctx.decoded);
      }),
  },
);

// Instance-level handler (runs second — specific to this usage)
MyForm.provide(
  {
    defaults: { name: "" },
    onSubmit: (ctx) =>
      Effect.gen(function* () {
        yield* saveToServer(ctx.decoded);
      }),
  },
  children,
);

The submit context provides:

interface SubmitContext<Encoded, Decoded> {
  encoded: Encoded;           // Raw form values
  decoded: Decoded;           // Validated/transformed values
  form: {
    isValid: boolean;
    errors: ParseIssue[];
    touched: ReadonlySet<string>;
    dirty: ReadonlySet<string>;
  };
}

Progressive Enhancement

When using @effex/platform, forms can work without JavaScript via the action property:

MyForm.provide(
  {
    defaults: { name: "" },
    action: actions.create,   // from RouteDataContext
    onSubmit: (ctx) =>
      Effect.gen(function* () {
        yield* Effect.tryPromise(() =>
          fetch(actions.create, {
            method: "POST",
            body: JSON.stringify(ctx.encoded),
          }),
        );
      }),
  },
  $.form(
    { class: "my-form" },
    // ...fields
  ),
);

When action is provided, the rendered <form> gets action and method="POST" attributes. Without JS, the form submits natively to the server. With JS, onSubmit intercepts and handles it client-side.

Complex Validation

Use Effect Schema's full power for validation:

const RegistrationForm = Form.make({
  username: Field.make(
    Schema.String.pipe(
      Schema.minLength(3, { message: () => "Too short" }),
      Schema.maxLength(20, { message: () => "Too long" }),
      Schema.pattern(/^[a-z0-9_]+$/, {
        message: () => "Only lowercase letters, numbers, and underscores",
      }),
    ),
  ),
  email: Field.make(
    Schema.String.pipe(Schema.nonEmptyString({ message: () => "Required" })),
  ),
  age: Field.make(
    Schema.Number.pipe(
      Schema.int({ message: () => "Must be a whole number" }),
      Schema.greaterThanOrEqualTo(18, { message: () => "Must be 18+" }),
    ),
  ),
});

API Reference

Field

| Function | Description | |---|---| | Field.make(schema, config?) | Create a leaf field from an Effect Schema | | Field.make(fields, config?) | Create a struct field from nested fields | | Field.Array(element, config?) | Create an array field | | Field.Map(keySchema, element, config?) | Create a map field | | isField(value) | Type guard for any field | | isLeafField(value) | Type guard for leaf fields | | isStructField(value) | Type guard for struct fields | | isArrayField(value) | Type guard for array fields | | isMapField(value) | Type guard for map fields |

FieldConfig

interface FieldConfig {
  validateOn?: "blur" | "change" | "submit";
  debounce?: number;  // ms, for "change" validation
}

Form

| API | Description | |---|---| | Form.make(fields, config?) | Create a form definition | | form.provide(config, children) | Create live state, provide context to children | | form.fields.<name> | Effect that yields the named field's state | | form.form | Effect that yields form-level state | | isForm(value) | Type guard |

FormConfig

interface FormConfig {
  validateOn?: "blur" | "change" | "submit";
  debounce?: number;
  onSubmit?: (ctx: SubmitContext) => Effect<void>;
}

ProvideConfig

interface ProvideConfig {
  defaults: Encoded;                               // Initial values for all fields
  onSubmit?: (ctx: SubmitContext) => Effect<void>;  // Instance-level submit handler
  action?: string;                                 // Native form action for progressive enhancement
}

Acknowledgments

The schema-first, context-based architecture of this package was inspired by effect-form.