Skip to content

mrblippy/react-typed-forms

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

npm

React typed forms

Yes, another form library for React. Why?

To take advantage of Typescript's advanced type system (v4.1) to give you more safety and a nice dev experience within your IDE.

Other reasons to use this library:

  • Zero re-rendering of parent components
  • Easy validation including async validators
  • Standard form related state (valid, disabled, dirty, touched, error string)
  • Arrays and nested forms
  • Zero dependencies besides React
  • MUI TextField binding

Install

npm install @react-typed-forms/core

Simple example

import { Finput, buildGroup, control } from "@react-typed-forms/core";
import { useState } from "react";
import React from "react";

interface SimpleForm {
  firstName: string;
  lastName: string;
}

const FormDef = buildGroup<SimpleForm>()({
  firstName: "",
  lastName: control("", (v) => (!v ? "Required field" : undefined)),
});

export default function SimpleExample() {
  const [formState] = useState(FormDef);
  const { fields } = formState;
  const [formData, setFormData] = useState<SimpleForm>();
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        setFormData(formState.toObject());
      }}
    >
      <label>First Name</label>
      <Finput id="firstName" type="text" state={fields.firstName} />
      <label>Last Name *</label>
      <Finput id="lastName" type="text" state={fields.lastName} />
      <div>
        <button id="submit">Validate and toObject()</button>
      </div>
      {formData && (
        <pre className="my-2">{JSON.stringify(formData, undefined, 2)}</pre>
      )}
    </form>
  );
}

Define your form

In order to render your form you first need to define it's structure, default values and validators.

The function buildGroup<T>() can be used to create a definition that matches the structure of your form data type. This comes in handy when you are creating forms based on types which are generated from a swagger or OpenAPI definition.

interface SimpleForm {
  firstName: string;
  lastName: string;
}

const FormDef = buildGroup<SimpleForm>()({
  firstName: "",
  lastName: control("", (v) => (!v ? "Required field" : undefined)),
});

control<V>(defaultValue) is used to define a control which holds a single immutable value of type V. When used within buildGroup the type will be inferred.

Instead of starting with a datatype and checking the form structure, you can also go with a form first approach:

const FormDef = groupControl({
  firstName: "",
  lastName: control("", (v) => (!v ? "Required field" : undefined)),
});

type SimpleForm = ValueTypeForControl<ControlType<typeof FormDef>>;

Render your form

With the form defined you need to initialise it within your component by using the useState() hook:

  const [formState] = useState(FormDef);

This will return an instance of GroupControl which has a fields property which contains FormControl instances.

The core library contains an <input> renderer for FormControl called Finput which uses html5's custom validation feature to show errors.

  return (
    <div>
      <Finput type="text" state={formState.fields.firstName} />
      <Finput type="text" state={formState.fields.lastName} />
    </div>
  );

There is also a small library (@react-typed-forms/mui) which has some renderers for the MUI TextField component.

Rendering

Creating renderers for a FormControl is very easy, it's a simple matter of using a hook function to register change listeners.

The easiest way is to just use useControlStateVersion() to trigger a re-render whenever any change that needs to be re-rendered occurs.

The most low level change listener hook is useControlChangeEffect() which just runs an effect function for the given change types.

Let's take a possible implementation Finput implementation which uses both:

// Only allow strings and numbers
export type FinputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  state: FormControl<string | number>;
};

export function Finput({ state, ...others }: FinputProps) {
  // Re-render on value or disabled state change
  useControlStateVersion(state, ControlChange.Value | ControlChange.Disabled);

  // Update the HTML5 custom validity whenever the error message is changed/cleared
  useControlChangeEffect(
    state,
    (s) =>
      (state.element as HTMLInputElement)?.setCustomValidity(state.error ?? ""),
    ControlChange.Error
  );
  return (
    <input
      ref={(r) => {
        state.element = r;
        if (r) r.setCustomValidity(state.error ?? "");
      }}
      value={state.value}
      disabled={state.disabled}
      onChange={(e) => state.setValue(e.currentTarget.value)}
      onBlur={() => state.setTouched(true)}
      {...others}
    />
  );
}

Other listener hooks

useAsyncValidator()

If you need complex validation which requires calling a web service, call useAsyncValidator() with your validation callback which returns a Promise with the error message (or null/undefined for valid). You also pass in a debounce time in milliseconds, so that you don't validate on each keypress.

useControlValue()

If you need to re-render part of a component based on the value of a FormComponent, use the userControlValue() hook:

function UseControlValueComponent() {
  const [titleField] = useState(control(""));
  const title = useControlValue(titleField);
  return (
    <div>
      Title: <Finput state={titleField} type="text" />
      <br />
      <h1>The title is {title}</h1>
    </div>
  );
}

useControlState()

A common scenario for forms is that you'd like to have a Save button which is disabled when the form is invalid.

import {useControlChangeEffect} from "@react-typed-forms/core";

const [formValid, setFormValid] = useState(formState.valid);
useControlChangeEffect(formState, () => setFormValid(formState.valid), ControlChange.Valid);

//...render form...
<button disabled={!formValid} onClick={() => save()}>Save</button>

useControlState() handles the state updates for you so you could replace the above code with:

const formValid = useControlState(formState, (c) => c.valid, ControlChange.Valid);

NOTE: useControlValue is just useControlState using the value.

useControlStateComponent()

The only downside to useControlState() is that you will be re-rendering the whole component, which usually won't matter if it's not too complicated but we can do better.

useControlStateComponent() creates a component which takes a function that passes in the computed state value and only renders that when it changes.

const FormValid = useControlStateComponent(formState, (c) => c.valid, ControlChange.Valid);
// ...render form...
<FormValid>
    {(formValid) => (
        <button disabled={!formValid} onClick={() => save()}>
            Save
        </button>
    )}
</FormValid>

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 66.3%
  • JavaScript 28.5%
  • C# 4.8%
  • Shell 0.4%