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

react-data-form

v0.2.0

Published

Data-driven React forms: declarative definition, field controllers, groups and steps — built for JSON-LD / API Platform APIs.

Readme

react-data-form

react-data-form

Documentation & live demos

Data-driven React forms: you describe a form as an object, the library renders the fields, holds the state, validates, and reports the errors your API sends back.

Built for JSON-LD / API Platform backends, but usable with any of them.

import { DatePickerInputController, FormElement, useForm } from "react-data-form"

const formContext = useForm({
  form: {
    label: { title: "My profile" },
    inputs: {
      firstName: { label: "First name" },
      email: { type: "email", label: "Email" },
      birthDate: { label: "Born on", controller: DatePickerInputController },
    },
    onSubmit: (data) => api.patch("/me", data),
  },
})

return <FormElement {...formContext} />

Contents

Installation

pnpm add react-data-form react-mini-i18n resource-registry

react and react-dom (18.3+ or 19), react-mini-i18n and resource-registry are peer dependencies — your copies are the ones used.

Those two are peer dependencies rather than plain dependencies because each owns a module-level singleton: a translation dictionary on one side, a resource registry on the other. Two copies in node_modules would mean two dictionaries and two registries, so half your translations would appear ignored and forms registered on one side would be invisible from the other.

Styles

The components are written with Tailwind CSS v4 classes backed by the shadcn theme variables. Tailwind must scan the library's compiled files to generate those classes:

@import "tailwindcss";
@source "../node_modules/react-data-form/dist";

If your application has no shadcn theme yet, import the neutral one shipped here — otherwise do not import it, and the library will pick up your own palette:

@import "react-data-form/styles.css";

Configuration

The library assumes no backend, no router and no visual identity. Those touch points go through ports injected once at startup. All of them have defaults, so the library works with no configuration at all.

import { configurePorts } from "react-data-form"
import { fr } from "date-fns/locale"

configurePorts({
  // Locale used by the date fields. Defaults to US English.
  dateLocale: fr,
  // BCP 47 tag used by Intl for dates and amounts, and the currency for prices.
  intlLocale: "fr-FR",
  currency: "EUR",

  components: {
    // Renders the readable label of an IRI in dropdowns.
    // By default the raw identifier is shown.
    iriLabel: ({ iri }) => <ResourceName iri={iri} />,
    // Brand mark shown at the centre of the loader. Defaults to nothing.
    logo: MyLogo,
  },
})

Form-wide behaviour is set separately, through setFormConfig:

import { setFormConfig } from "react-data-form"

setFormConfig({
  defaultForm: {
    label: { success: "Saved", error: "The form is invalid" },
  },
})

Translation

Labels go through react-mini-i18n, so your application and the forms share one dictionary:

import { setTranslation } from "react-mini-i18n"

setTranslation({ "My profile": "Mon profil" })

Entry points

| Import | Contents | | ----------------------- | ---------------------------------------------------------------- | | react-data-form | Core: useForm, FormElement, field controllers, configuration | | react-data-form/group | Splitting fields into collapsible sections | | react-data-form/media | Media fields: upload, gallery, image editor | | react-data-form/step | Multi-step forms with navigation |

Concepts

A form is data

A form is a FormInterface object describing its fields, labels, action and behaviour. Nothing is hardcoded in JSX, so the same description can be stored, transformed, or generated from an API schema.

const form: FormInterface = {
  action: ActionList.create,
  inputs: {
    name: { label: "Name", required: true },
    price: { label: "Price", controller: PriceInputController },
  },
}

Fields delegate rendering to a controller

Every field is rendered by a controller: a React component receiving { formInput, onChange } and needing to know nothing else about the form.

Without a controller, a field falls back to DefaultInputController, an HTML <input> driven by the field's type (text, email, number, password…). For anything else, name the controller explicitly:

inputs: {
  description: { controller: WysiwygInputController },
}

A field carrying a form key is rendered as a nested sub-form.

Writing your own controller means writing a component that satisfies InputControllerInterface — no registration step involved:

const ColorInputController = ({ formInput, onChange }: InputControllerInterface) => (
  <input
    type="color"
    value={formInput.value ?? "#000000"}
    onChange={(e) => onChange({ ...formInput, value: e.target.value })}
  />
)

The form registry

addForm registers a form under an IRI and getForm finds it back by type, both exported from the main entry point:

import { addForm, getForm } from "react-data-form"
import { createResource } from "resource-registry"

Both write into the same registry. If your application also declares resources, import createResource from resource-registry rather than keeping a copy of it: two registries in memory would keep searchMetaData from connecting a resource to its form.

That registry is what makes asymmetric arrays possible: a field can render entries whose shapes it does not know at build time, as long as each entry names the form it was created from.

Asymmetric arrays

An array field whose entries do not share a shape — a title, a rich text, a gallery — is what a page builder needs. Three steps.

Register each shape. @for is the tag: it is what an entry stores as its type, and what the palette is asked for. name and icon are what the palette displays.

import { addForm, WysiwygInputController } from "react-data-form"
import { Heading, Text } from "lucide-react"

addForm("titleForm", {
  "@for": ["title"],
  name: "Title",
  icon: Heading,
  inputs: {
    order: {},
    data: { label: "Your title" },
  },
})

addForm("longTextForm", {
  "@for": ["long-text"],
  name: "Rich text",
  icon: Text,
  inputs: {
    order: {},
    data: { label: "Your text", controller: WysiwygInputController },
  },
})

Offer them in a field. createFormArrayInputController builds the field and wires FormArrayInputController as its controller. forms names the tags the insertion palette offers — those, and nothing else, even if the registry holds more.

import { createFormArrayInputController } from "react-data-form"

const form: FormInterface = {
  inputs: {
    blocks: createFormArrayInputController({
      label: "Blocks",
      draggable: true,
      identifierKey: "order",
      forms: ["title", "long-text", "gallery"],
    }),
  },
}

Let each entry find its shape back. Picking Gallery in the palette appends { id, type: "gallery", order } to the value. At render, every entry is looked up with getForm({ type }), so nothing about the shapes is hardcoded in the field: store the array as-is and it renders back the same.

Options accepted by createFormArrayInputController, on top of the usual field keys:

| Option | Effect | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | forms | The @for tags offered in the palette. An empty array is not the same as omitting the key: it offers every registered form. Omitted entirely, there is no palette at all — just an "Add" button appending a blank entry built from form. | | draggable | Adds a grip handle to each block header and lets a block be dropped onto another, which reindexes the whole array. | | closedByDefault | Mounts every block folded. A block header always carries a chevron folding its body away — handle and order stay, so a long page stays reorderable — and folding is display state only, never part of the value. A block just added opens. | | identifierKey | The key holding the position, defaulting to "order". It is edited from the block header, and hidden from the block body when the shape declares it as a field. | | defaultValue | The entries the field starts with, as a value or a function returning one. | | form | The fallback shape, rendering entries that carry no type, or one no registered form answers to. With a palette wired up every entry has a type, so it rarely comes into play. |

A live, manipulable example sits on the Asymmetric forms page of the documentation site.

Available field controllers

All exported from react-data-form, to be passed as a field's controller.

TextDefaultInputController, TextAreaInputController, PasswordInputController, EmailInputController, WebsiteInputController, PhoneInputController, WysiwygInputController, SearchInputController, AutocompleteInputController.

NumbersNumberInputController, PriceInputController, DurationInputController.

DatesDateInputController, DatePickerInputController, DateRangeInputController, TimeInputController, SelectTimeInputController, MomentInputController.

ChoicesSelectInputController, SelectSearchInputController, MultiSelectInputController, MultiSelectSearchInputController, SelectRadioInputController, SelectCardInputController, SelectButtonInputController, CheckboxInputController, SwitchInputController, BooleanInputController, IconInputController.

CompositeArrayInputController, FormArrayInputController (page builder), FormInputController (sub-form), BlockOrderInput, FileInputController, IaImageInputController.

Mediareact-data-form/media ships MediaObjectInputController (upload, drag & drop, document preview), MediaObjectGalleryInputController and ImageEditor, a crop-and-rotate editor.

Media go through a port

The media controllers know how to render an upload; they know nothing about the API behind it. Everything they need is described by MediaObjectPortInterface, which the application implements and injects — so no route, no payload and no authentication ever reaches the library.

import {
  MediaObjectProvider,
  MediaObjectPortInterface,
} from "react-data-form/media"

const mediaObjectPort: MediaObjectPortInterface = {
  upload: async ({ base64, role, gallery }) => {
    const media = await api.post("/api/media_objects", {
      fileInBase64: base64,
      role,
      gallery,
    })
    return media["@id"]
  },
  replace: async ({ iri, base64 }) => { /* … */ },
  remove: async (iri) => { /* … */ },
  describe: async (iri) => ({ mimeType: "image/png", label: "photo.png" }),
  toUrl: (iri, options) => (iri ? `/files/${iri}${options?.download ? "?download=1" : ""}` : undefined),
  gallery: {
    create: async ({ role }) => ({ iri: "…", items: [] }),
    read: async (iri) => ({ iri, items: [] }),
  },
  // The library ships no toast of its own: plug yours here.
  onError: (message) => toast.error(message),
}

<MediaObjectProvider port={mediaObjectPort} labels={{ edit: "Modifier" }}>
  <App />
</MediaObjectProvider>

useMediaObjectPort() gives access to the port and throws an explicit error outside of the provider. Labels default to English and are overridden one by one on the provider. For a test, a story or a demo, createInMemoryMediaObjectPort() implements the whole port in memory, with no server behind it.

Development

pnpm install
pnpm test          # 174 tests (Vitest + Testing Library)
pnpm typecheck
pnpm lint
pnpm build         # tsdown → dist/ (ESM + types)

Releasing

Versioning goes through changesets:

pnpm changeset       # describe the change and its impact (patch / minor / major)
git commit && git push

CI publishes to npm once merged to main.

Author

Written by Salvador Cardona — cardona.digital.

License

MIT