Skip to content

doolse/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 to give you more safety and a nice dev experience within your IDE.

  • Signals style reactive programming
  • 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, notEmpty, useControl } from "@react-typed-forms/core";
import React, { useState } from "react";

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

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

Initialise a Control

A Control is essentially an advanced signal with additional state for tracking form metadata, see Control Properties and the ability to treat object fields and array elements as child Controls.

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

export default function SimpleExample() {
  const formState = useControl(
    { firstName: "", lastName: "" },
    { fields: { lastName: { validator: notEmpty("Required field") } } }
  );
  const fields = formState.fields;

useControl<V>(initialValue, configure) is used to define a control which holds an immutable value of type V.

Because formState is a Control which holds a value of type SimpleForm, you can access a child Control by using the parent's fields property.

      <label>First Name</label>
      <Finput id="firstName" type="text" control={fields.firstName} />
      <label>Last Name *</label>
      <Finput id="lastName" type="text" control={fields.lastName} />

Finput is a simple wrapper component around the standard DOM input tag, which supports showing validation errors with HTML5 setCustomValidity(). The important thing to note here is that the parent component will not need to be re-rendered while typing, as would be needed with the standard useState() style form rendering.

Along with Finput, the core library provides Fselect and Fcheckbox. There is also a small library (@react-typed-forms/mui) which has renderers for various MUI components.

Control properties

Every Control implements ControlProperties:

export interface ControlProperties<V> {
  value: V;
  initialValue: V;
  error: string | null | undefined;
  readonly errors: { [k: string]: string };
  readonly valid: boolean;
  readonly dirty: boolean;
  disabled: boolean;
  touched: boolean;
  readonly fields: V extends string | number | Array<any> | undefined | null
    ? undefined
    : V extends { [a: string]: any }
    ? { [K in keyof V]-?: Control<V[K]> }
    : V;
  readonly elements: V extends (infer A)[]
    ? Control<A>[]
    : V extends string | number | { [k: string]: any }
    ? never[]
    : V;
  readonly isNull: boolean;
}

A control is valid if it has an empty error message AND all of it's children controls are valid.

A control is dirty if the initialValue is not equal to the value.

A control's touched flag generally gets set to true onBlur() and is generally used to prevent error messages from showing until the user has attempted to enter a value.

Rendering with Controls

Custom rendering of a Control boils down to the useControlValue() hook primitive. It behaves like computed() or effect(), but instead of re-renders the current component whenever any referenced Control property changes. For example let's say you didn't want users to be able to click the save button unless they'd changed the data in the form and the form was valid, you could do this:

const form = useControl({firstName: "Joe", lastName: "Blogs"});
const canSave = useControlValue(() => form.valid && form.dirty);
...
<button disabled={!canSave}>Save</button>

The react component which uses useControlValue() will re-render whenever the value returned from the callback changes, and that value will be re-computed whenever any of the requested properties changes (in this case the valid and dirty flags).

useControlValue() also has a version which just takes a single control and is the equivalent of using () => control.value.

const countControl = useControl(0);
const currentCount = useControlValue(countControl);

The trouble with using useControlValue() is that it will still re-render the whole component, much like standard useState() does, whereas often the computed value may only affect a small part of the components rendering. The solution in this case is to use the RenderControl component. Which is a simple wrapper around useControlValue which allows you to only re-render what you need:

<RenderControl>{() => <button disabled={!form.valid || !form.dirty}>Save</button>}</RenderControl>

There is another component specifically for rendering standard form like controls, which gives you some properties which you can usually directly add to DOM elements:

export interface FormControlProps<V, E extends HTMLElement> {
    value: V;
    onChange: (e: ChangeEvent<E & { value: any }>) => void;
    onBlur: () => void;
    disabled: boolean;
    errorText?: string | null;
    ref: (elem: HTMLElement | null) => void;
}

The Finput component simply passes the properties through to the <input> tag.

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

export function Finput<V extends string | number>({
  control,
  ...props
}: FinputProps<V>) {
  // Update the HTML5 custom validity whenever the error message is changed/cleared
  useControlEffect(
    () => control.error,
    (s) => (control.element as HTMLInputElement)?.setCustomValidity(s ?? "")
  );
  const { errorText, value, ...inputProps } = formControlProps(control);
  return (
    <input
      {...inputProps}
      value={value == null ? "" : value}
      ref={(r) => {
        control.element = r;
        if (r) r.setCustomValidity(control.current.error ?? "");
      }}
      {...props}
    />
  );
}

Control Effects

You can run effects directly from changes to a Control by using the useControlEffect() hook.

function useControlEffect<V>(
  compute: () => V,
  onChange: (value: V) => void,
  initial?: ((value: V) => void) | boolean
): void;

The compute parameter calculates a value, if the value ever changes (equality is a shallow equals), the onChange effect is called. The initial callback will be called first time if it is passed in, or if true is passed in it will simply call the onChange handler first time.

Validation

Synchronous validation can be added to a control upon initialisation via the configure parameter of useControl().

const mustBeHigherThan4 = useControl(0, {validator: (v: number) => v > 4 ? undefined : "Please enter a number greather than 4" })

Arrays

A Control containing an array can split each element out as it's own Control by using the RenderElements component.

export function ListOfTextFields() {
  const textFields = useControl<string[]>([]);

  return (
    <div>
      <RenderElements
        control={textFields}
        children={(x) => <Finput control={x} />}
      />
      <button onClick={() => addElement(textFields, "")}>Add</button>
    </div>
  );
}

You can simple set the array value directly on the parent, or you can use the following functions to manipulate the elements.

function addElement<V>(control: Control<V[] | undefined | null>, child: V,
           index?: number | Control<V> | undefined, insertAfter?: boolean): Control<V>
function removeElement<V>(control: Control<V[] | undefined>, child: number | Control<V>): void 

Other 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.

TODO

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •