Skip to content

🆕 Create high performance forms with type-safe configurations, rules and plugins.

License

Notifications You must be signed in to change notification settings

ruuddrummen/react-signal-forms

Repository files navigation

React Signal Forms · npm build github-pages

⚠️ DISCLAIMER. The API is not yet stable, i.e. any version may introduce breaking changes.

A forms library which aims to provide a high performance modular experience by leveraging signals with @preact/signals-react.

  • Easy to use, easy to extend. Built from the ground with a DX friendly plugin API.
    • Pick and choose from the built-in plugins that fit your needs.
    • Plug in your own.
  • Add built-in context aware and typesafe rules to your fields or create your own.
    • Like required(), requiredIf(...), applicableIf(...), computed(...), etc.
  • Only calculates and renders what is necessary without you needing to think about it.
  • Field and rule specifications are separated from presentation, so UI components don't get clogged with configuration and business rules.
  • Bring your own UI libraries and components.
  • All strongly typed with TypeScript.

Table of contents

Getting started

npm i react-signal-forms

Exploring the demo

For a quick first look you can check out the demo with Material UI, or run it yourself by cloning the repository and running:

npm run ci
npm run demo

If you want to explore the code, a good place to start would be the basics example.

Your first form

Start by initializing your form component and hooks with the plugins you want to use:

// Add plugins, built-in or your own.
export const { SignalForm, useForm, useField } = createSignalForm(
  ...defaultPlugins, // the defaults, includes validation rules and touched field signals.
  plugins.applicabilityRules // adds applicability rules and field signals.
)

ℹ️ A full list of the currently available plugins can be found in the plugins module.

Create field specifications for your forms:

interface ExampleData {
  justText: string
  aFieldWithRules: string
  aSelectField: string
}

const fields = signalForm<ExampleData>().withFields((field) => {
  //                      ^ All specifications and rules will be strongly
  //                        typed based on your data interface or type.

  ...field("justText", "Just a text field"),

  ...field("aFieldWithRules", "A field with some rules", {
    defaultValue: "Demo",

    // Add rules to your field. Some examples:
    rules: [
      required(),
      minLength(6),
      requiredIf(({ form }) => form.fields.otherField.value === true),
      applicableIf(({ form })) => form.field.otherField.value === true)
    ]
  })

  ...field("aSelectField", "Select field").as<SelectField>({
    //       Plug in any field type you need, ^
    //       built-in or your own.
    options: [
      /* ...items */
    ]
  })

})

Access field context in your input components with useField():

interface TextInputProps {
  field: TextField // only accepts string fields.
}

export const TextInput = ({ field }: TextInputProps) => {
  const {
    value,
    setValue,
    isValid,
    errors,
    isApplicable,
    ...otherSignals
    // ^ With intellisense matching your selected plugins.
  } = useField(field)

  if (!isApplicable) {
    return null
  }

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.currentTarget.value)}
      {...otherProps}
    />
  )
}

Access form context in components such as a submit button with useForm():

export const SubmitButton = () => {
  const {
    submit,
    isSubmitting,
    peekValues,
    isValid,
    // ...
  } = useForm()

  return <a onClick={() => submit(peekValues())}>Submit</a>
}

ℹ️ peekValues() reads all form values without subscribing to any signals, which would trigger re-renders on any value update.

You are now set to compose your form:

const MyForm = () => {
  return (
    <SignalForm
      fields={fields}
      initialValues={valuesFromStore}
      onSubmit={handleSubmit}
    >
      <TextInput field={fields.justText} />
      <TextInput field={fields.aFieldWithRules} />
      <SelectInput field={fields.aSelectField} />
      <SubmitButton />
    </SignalForm>
  )
}

How it works: signals and field rules

All internal state management is handled with signals. Field rules are executed in computed signals, which by definition subscribe to the signals they reference, and nothing more. An advantage of this approach is that rules automatically subscribe to the state that they reference, and are only re-evaluated when state used in the rule is updated. Even larger and more complex forms should still perform well without requiring manual optimizations.

A simple example to illustrate what this means for performance: if field A is only applicable when field B has a specific value, then:

  • The applicability rule is only evaluated when the value of field B is updated. Updates on any other field do not trigger the evaluation of the rule.
  • Field A is only re-rendered when the result of the applicability rule changes, i.e. from true to false or vice versa.

Features

Field types

The library comes with some built-in field types which add additional configuration options, such as the SelectField shown in Your first form. You can also create and configure any field type you need. An example:

type Address = {
  country: string
  postalCode: string
  number: string
  // ...
}

interface AddressField extends FieldBase<Address> {
  countryFilter: string
  // ...
}

const fields = signalForm<ExampleData>().fields((field) => ({
  ...field("address", "Address").as<AddressField>({
    countryFilter: "NL",
    // ...
  }),
}))

const AddressInputComponent = (props: { field: AddressField }) => {
  // Access your address field properties here...
}

Array fields

The implementation of forms with one or more arrays of items is supported by array fields. You can create specifications for an array field with ...field("yourArrayField").asArray(...).

For example:

type ExampleData = {
  arrayField: Array<{
    booleanField: boolean
    textField: string
  }>
}

const fields = signalForm<ExampleData>().withFields((field) => ({
  ...field("arrayField").asArray({
    fields: (field) => ({
      ...field("booleanField", "Toggle field"),
      ...field("textField", "Text field"),
    }),
  }),
}))

The array field itself and all fields in an array field support the same features and plugins as other fields. Note that field rules in an array form also have access to the parent form.

For example:

...field("textFieldInArray", "Text field in array", {
  rules: [
    applicableIf(
      ({ form }) => form.parent.fields.fieldInParent.value === "some value"
    )
  ]
})

Adding array fields to your form can then be done with the useArrayField() hook and the <ArrayItem /> component. The hook provides a description of the items in the array, which can then be mapped to the ArrayItem component.

For example:

const YourForm = () => (
  <SignalForm fields={yourFields}>
    {/* ... */}
    <YourArrayField />
    {/* ... */}
  </SignalForm>
)

const YourArrayField = () => {
  const { items, itemFields, add } = useArrayField(yourFields.arrayField)
  //             ^ can also be accessed with `yourFields.arrayField.fields`.

  return (
    <>
      {items.map((item) => (
        <YourLayout key={item.id}>
          {/*       ^ make sure to set `key` to `item.id` */}

          <ArrayItem item={item}>
            <TextInput field={itemFields.textField}>

            {/* Other layout and input components */}

            <Button onClick={item.remove}>Remove item</Button>
          </ArrayItem>
        </YourLayout>
      ))}

      <Button onClick={add}>Add item</Button>
    </>
  )
}

The demo includes an example for array fields, and you can find the code in ArrayFieldsDemo.

ℹ️ For better performance when adding and removing items, wrap your array items in React.memo(). In the example above this could be done on the <YourLayout /> component, and you can also find it used in the demo.

Nested forms

Planned.

Plugin API

Form features such as validation and applicability rules are implemented as plugins with the plugin and field rule API's. The goal behind this concept is:

  • to keep feature implementations separate and simple, and
  • to make adding features easier, both in the library and in projects using the library.

In most simpler cases, the native plugins should be enough to get you going. If necessary though, plugins and rules can be added or replaced to fulfill on specialized requirements.

ℹ️ All native plugins use the methods described below, so you can use those as examples.

createPlugin()

Plugins can be replaced and you can create and plug in your own to better fit your requirements. To do this you can use the createPlugin() method. To get started you can have a look at the initialValue and readonlyRules plugins, which are some of the simpler ones.

createFieldRule()

Field rules can be added to any plugin using them. In general, rules can be created with the createFieldRule() helper function. This function can be used as is, or it can be wrapped for specific plugins. For example, the validation plugin has wrapped this function in createValidationRule().