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

@theokit/plugin-forms

v0.5.2

Published

Declarative form binding for TheoKit: zod + react-hook-form + useAction. Ships <TheoForm action={actions.X}> + <TheoField name> + applyActionErrorsToForm adapter.

Readme

@theokit/plugin-forms

Declarative form binding for TheoKit. Glues zod + react-hook-form + useAction (from @theokit/react) into a single <TheoForm action={actions.X}> component. Field-level errors from ActionInputError.fields map straight into the form via a small adapter; pending state flows through Context.

Status: v0.1.0 (early). Requires JavaScript on the client (no progressive enhancement in v0.1 — see Limitations).

Install

pnpm add @theokit/plugin-forms react-hook-form @hookform/resolvers zod@^4
# Required. The package has ONE entry point and it imports @usetheo/ui at module scope,
# so without this the import fails — see "The headless tier is not reachable" below.
pnpm add @usetheo/ui

Peer-dep matrix:

| Package | Range | Required? | | --------------------- | --------------------- | -------------------------------------------------------------- | | react | >=19.0.0 | yes | | react-hook-form | ^7.50.0 | yes | | @hookform/resolvers | ^5.0.0 | yes | | zod | ^3.25.0 \|\| ^4.0.0 | yes (matches @theokit/sdk peer range) | | theokit | >=0.2.3 | yes (G3 __zodSchema extension) | | @theokit/react | >=1.1.0 | yes (useAction hook) | | @usetheo/ui | >=0.22.0 <1 | required — the only entry point imports it at module scope |

zod@^4 is required, and npm refuses without it

Pin zod 4 explicitly. npm install @theokit/plugin-forms on its own fails with ERESOLVE, and the reason is a zod major split between two optional-peer chains neither this package nor npm can reconcile:

| Chain | Requires | | ----------------------------------------------------------------------- | ------------- | | @hookform/resolvers@5@typeschema/main@typeschema/[email protected] | zod@^3.23.8 | | @theokit/[email protected]@theokit/[email protected] | zod@^4.0.0 |

npm resolves zod to the 3.x ceiling of the first chain and then refuses the second. Both sides are optional peers and it refuses anyway — which is why --legacy-peer-deps works and pnpm only warns. Naming zod@^4 at the root resolves it.

The underlying cause is outside this package: @theokit/react has a single published version pinned to the @theokit/[email protected] line, three majors behind current. Tracked in #64; this note goes away when a @theokit/react on the current SDK line ships.

Convention — shared schemas

Author each action's input schema in an isomorphic file under server/actions/schemas/<name>.ts:

// server/actions/schemas/save-memory.ts
import { z } from 'zod'
export const schema = z.object({
  conversationId: z.string().min(1),
  content: z.string().min(1),
})

Then import it from the action handler:

// server/actions/save-memory.ts
import { action } from 'theokit/server'
import { schema } from './schemas/save-memory.js'

export const saveMemory = action()
  .input(schema)
  .handler(async ({ input }) => {
    // persist input.content under input.conversationId
    return { id: 'mem_...' }
  })
  .build()

The TheoKit Vite plugin detects the convention and exposes the schema at runtime as actions.saveMemory.__zodSchema. <TheoForm> reads it to drive RHF's zodResolver — no client re-declaration.

Cookbook 1 — basic form with <TheoForm.Field> (styled tier)

'use client'
import { actions } from '@theo/actions'
import { TheoForm, TheoField, useTheoFieldRegister, useTheoFieldScope } from '@theokit/plugin-forms'
import { FormField, Input, Button } from '@usetheo/ui'

// `FormField.Control` clones its DIRECT child to inject `id`, `aria-invalid` and
// `aria-describedby`. So the direct child has to be the real `<Input>`: a component of
// your own in that slot receives those props and drops them, leaving the label pointing
// at an id nothing has and the error announced to nobody (#105). Keep `FormField.Control`
// INSIDE the component that calls the hook.
function ControlForCurrentField() {
  const register = useTheoFieldRegister()
  return (
    <FormField.Control>
      <Input {...register} placeholder="Type something..." />
    </FormField.Control>
  )
}

// `FormField.Error` renders its CHILDREN — it does not read the message from anywhere. Left
// self-closing it shows an empty alert: the field goes `aria-invalid`, the icon appears, and
// the reason the server gave is dropped (#106). `useTheoFieldScope()` is exported for this.
function ErrorForCurrentField() {
  const { error } = useTheoFieldScope()
  return <FormField.Error>{error?.message}</FormField.Error>
}

export default function MemoryPage() {
  return (
    <TheoForm
      action={actions.saveMemory}
      defaultValues={{ conversationId: 'default', content: '' }}
      onSuccess={(data) => console.log('Saved:', data)}
    >
      <input type="hidden" name="conversationId" value="default" readOnly />
      <TheoField name="content">
        <FormField.Label required>Memory</FormField.Label>
        <ControlForCurrentField />
        <ErrorForCurrentField />
      </TheoField>
      <Button type="submit">Save</Button>
    </TheoForm>
  )
}

What's happening:

  • <TheoForm action={actions.saveMemory}> wires useAction + RHF useForm({resolver: zodResolver(actions.saveMemory.__zodSchema)}) + provides Context.
  • <TheoField name="content"> reads RHF state for the field; renders <FormField invalid={hasError}> from @usetheo/ui.
  • useTheoFieldRegister() inside the descendant input pulls RHF's register props and spreads them onto the <Input>.
  • On submit failure with ActionInputError, <FormField.Error/> populates from errors.content.message via the internal adapter.

Cookbook 2 — pending state via useTheoFormState

Submit buttons (and any descendant) read pending/error/data via Context:

import { useTheoFormState } from '@theokit/plugin-forms'
import { Button } from '@usetheo/ui'

function SubmitButton() {
  const { isPending, isError, error } = useTheoFormState()
  return (
    <>
      {isError && <p role="alert">{error?.message ?? 'Submission failed'}</p>}
      <Button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </Button>
    </>
  )
}

Cookbook 3 — headless useTheoField (no @usetheo/ui components)

For consumers who don't use @usetheo/ui (shadcn primitives, MUI, raw HTML):

'use client'
import { actions } from '@theo/actions'
import { TheoForm, useTheoField } from '@theokit/plugin-forms'

function MyField({ name, label }: { name: string; label: string }) {
  const field = useTheoField(name)
  return (
    <label>
      {label}
      <input {...field.register} />
      {field.error && <span role="alert">{field.error.message}</span>}
    </label>
  )
}

export default function MyForm() {
  return (
    <TheoForm
      action={actions.saveMemory}
      defaultValues={{ conversationId: 'default', content: '' }}
    >
      <input type="hidden" name="conversationId" value="default" readOnly />
      <MyField name="content" label="Memory content" />
      <button type="submit">Save</button>
    </TheoForm>
  )
}

The headless tier is not reachable

useTheoField itself needs nothing from @usetheo/ui. You cannot get to it without the package installed, and that is a packaging fact rather than a runtime one:

import('@theokit/plugin-forms')        -> ERR_MODULE_NOT_FOUND: Cannot find package '@usetheo/ui'
import('@theokit/plugin-forms/react')  -> ERR_PACKAGE_PATH_NOT_EXPORTED

package.json declares exactly one export, ., and the barrel reaches <TheoField> — which imports @usetheo/ui at module scope. So the failure happens when the module graph loads, before any component renders. Measured 2026-08-24 against a real consumer layout and pinned by integration/tests/consumer/missing-peer.offline.test.ts.

The obvious fix — a second entry point — was attempted and reverted: <TheoForm> imports <TheoField> to build the TheoForm.Field compound, so the barrel reaches it either way, and splitting: false would duplicate TheoFormContext and hand you two React contexts. Whether to build one anyway is a decision about the published surface; it has not been made.

Until then: install @usetheo/ui. useTheoField still works in any React stack once you have — what is untrue is that you can skip the dependency.

Field-error adapter — applyActionErrorsToForm

<TheoForm> calls this internally on submit failure, but it's exported for advanced use:

import { applyActionErrorsToForm } from '@theokit/plugin-forms'
import { useForm } from 'react-hook-form'

const form = useForm()
// After a custom mutation:
applyActionErrorsToForm(form.setError, {
  'user.name': ['Required'],
  'items.0.qty': ['Must be >= 1'],
  '': ['Form-level error'], // root → 'root' per RHF convention
})
// → errors.user.name.message === 'Required'
// → errors.items[0].qty.message === 'Must be >= 1'
// → errors.root.message === 'Form-level error'

First message per field wins (HTML5 single aria-describedby convention). For multi-message rendering, read formState.errors[name] directly.

File uploads

Files work. <TheoField> renders no input of its own, so a file control is just an input that spreads useTheoFieldRegister():

import * as React from 'react'
import { z } from 'zod'
import { TheoField, TheoForm, useTheoFieldRegister } from '@theokit/plugin-forms'

import { upload } from './my-action.js'

const schema = z.object({
  title: z.string(),
  docs: z.array(z.instanceof(File)),
})

function FileControl(): React.JSX.Element {
  const register = useTheoFieldRegister()
  return <input type="file" multiple {...register} />
}

export function UploadForm(): React.JSX.Element {
  return (
    <TheoForm action={upload as never} schema={schema as never} encType="multipart/form-data">
      <TheoField name="docs">
        <FileControl />
      </TheoField>
    </TheoForm>
  )
}

Three things are worth knowing, because each is a thing you would otherwise get wrong once:

  • z.array(z.instanceof(File)), not z.instanceof(File) — even for a single file. A registered file input holds a FileList, which this package normalises to File[] before validation. A single-file field is a one-element array on both sides.
  • encType="multipart/form-data" is required and is not inferred. Without it the values go as a plain object and JSON.stringify keeps the file's name and drops its bytes — your server stores an empty file and nothing errors. Conversion is not inferred from the values because an action whose input is an object on one submit and a FormData on the next cannot be typed.
  • Your action must declare accept: 'form' server-side. That is where the body is parsed, and this package cannot see it. Without it the server reads JSON and every field arrives empty.

The size limits (maxFileSize, maxFiles, total body) are the server's, and it enforces them while parsing — so a rejected upload is rejected after the bytes crossed the network. The rejection reaches the field like any other server error.

Limitations (v0.1)

  • Requires JavaScript on the client. No progressive-enhancement path in v0.1 — forms will not submit without JS. FormData wire (PE) is targeted for v0.2.
  • A multipart scalar array collapses to its last element. tags: ['a','b'] arrives as ['b']. The cause is in the framework's body parser, upstream of anything this package controls, and there is no client-side fix. Arrays of files are unaffected. Pinned by a test here so the day it is fixed, we find out.
  • No form arrays / wizards. RHF useFieldArray works inside <TheoForm> but plugin sub-parts don't ship special UX for it.
  • The package does not import at all without @usetheo/ui. Not "throws at first render" — the single entry point pulls it into the module graph, so import('@theokit/plugin-forms') fails with ERR_MODULE_NOT_FOUND before any component exists. useTheoField is not an escape hatch from that; see "The headless tier is not reachable".
  • Async zod refinements (.refine(async)) are stripped client-side. RHF cannot handle async resolvers cleanly; rely on the server's ActionInputError for those.
  • Shared-schema convention is required for __zodSchema auto-detection. If you keep input: z.object({...}) inline in defineAction(...), actions.X.__zodSchema is undefined and <TheoForm> falls back to no client-side validation (server-side ActionInputError still hydrates).

API surface

| Export | Kind | Notes | | ------------------------------------------- | --------- | ----------------------------------------------------------------------- | | TheoForm | Component | Root + Object.assign sub-part TheoForm.Field | | TheoField | Component | Styled tier (peer @usetheo/ui); same as TheoForm.Field | | useTheoField(name) | Hook | Headless tier — returns {value, error, isInvalid, register, setValue} | | useTheoFieldRegister() | Hook | Inside <TheoField> descendants — spread onto your input | | useTheoFieldScope() | Hook | Inside <TheoField> descendants — full field state | | useTheoFormState() | Hook | Form-level state (isPending, isSuccess, isError, error, data, reset) | | applyActionErrorsToForm(setError, fields) | Function | Pure adapter — maps ActionInputError.fields → RHF setError calls | | TheoFormContext | Context | Exported for advanced override |

Plus types: TheoFormProps, TheoFormAction, TheoFieldProps, UseTheoFieldResult, TheoFormContextValue, TheoFormErrorLike, ActionInputErrorLike, SetErrorCallback.

Roadmap

  • v0.2 — FormData wire + progressive enhancement, file uploads, useFieldArray integration
  • v0.3 — Standard Schema adapter (valibot/arktype alongside zod)

License

MIT — see LICENSE.