Skip to content

Releases: edmundhung/conform

v1.0.2

19 Feb 22:45
Compare
Choose a tag to compare

New APIs: unstable_useControl and <unstable_Control />

In v1, the useInputControl hook is introduced with ability to insert an hidden input for you. Unfortunately, this is found to be problematic in situations where you need to dynamically render the input. After some discussions, we believe it would be better stop supporting this and have developers deciding how they wanna render the hidden input instead. To avoid breaking changes on the useInputControl hook, a new useControl hook is introduced which works similar to useInputControl() except you are required to register the input element yourself:

Here is an example with Headless UI Listbox

function Select({
    name,
    options,
    placeholder,
}: {
    name: FieldName<string>;
    placeholder: string;
    options: string[];
}) {
    const [field] = useField(name);
    const control = useControl(field);

    return (
        <Listbox
            value={control.value ?? ''}
            onChange={value => control.change(value)}
        >
            {/* Render a select element manually and register with a callback ref */}
            <select
                className="sr-only"
                aria-hidden
                tabIndex={-1}
                ref={control.register}
                name={field.name}
                defaultValue={field.initialValue}
            >
                <option value="" />
                {options.map((option) => (
                    <option key={option} value={option} />
                ))}
            </select>
            <div className="relative mt-1">
                <Listbox.Button className="...">
                    <span className="block truncate">
                        {control.value ?? placeholder}
                    </span>
                    <span className="...">
                        <ChevronUpDownIcon
                            className="h-5 w-5 text-gray-400"
                            aria-hidden="true"
                        />
                    </span>
                </Listbox.Button>
                <Listbox.Options className="...">
                    {options.map((option) => (
                        <Listbox.Option
                            key={option}
                            className="..."
                            value={option}
                        >
                            {({ selected, active }) => (
                                <>
                                    <span className="...">
                                        {option}
                                    </span>

                                    {option !== '' && selected ? (
                                        <span className="...">
                                            <CheckIcon className="h-5 w-5" aria-hidden="true" />
                                        </span>
                                    ) : null}
                                </>
                            )}
                        </Listbox.Option>
                    ))}
                </Listbox.Options>
            </div>
        </Listbox>
    );
}

You might also find the <Control /> component in the render-props style useful when working with checkbox group in which we can use it to register each individual checkbox element without creating an additional component. Here is an example based on Radix Checkbox:

function CheckboxGroup({
    name,
    options,
}: {
    name: FieldName<string[]>;
    options: string[];
}) {
    const [field] = useField(name);
    // The initialValue can be a string or string array depending on how it was submitted before. 
    // To make it easy working with the initial value, we make sure it is always an array
    const initialValue =
        typeof field.initialValue === 'string'
            ? [field.initialValue]
            : field.initialValue ?? [];

    return (
        <div className="py-2 space-y-4">
            {options.map((option) => (
                <Control
                    key={option}
                    meta={{
                        key: field.key,
                        initialValue: initialValue.includes(option) ? option : '',
                    }}
                    render={(control) => (
                        <div
                            className="flex items-center"
                            ref={(element) => {
                                // Radix does not expose the inner input ref. That's why we query it from the container element
                                control.register(element?.querySelector('input'))
                            }}
                        >
                            <RadixCheckbox.Root
                                type="button"
                                className="flex h-[25px] w-[25px] appearance-none items-center justify-center rounded-[4px] bg-white outline-none shadow-[0_0_0_2px_black]"
                                id={`${field.id}-${option}`}
                                name={field.name}
                                value={option}
                                checked={control.value === option}
                                onCheckedChange={(state) =>
                                    control.change(state.valueOf() ? option : '')
                                }
                                onBlur={control.blur}
                            >
                                <RadixCheckbox.Indicator>
                                    <CheckIcon className="w-4 h-4" />
                                </RadixCheckbox.Indicator>
                            </RadixCheckbox.Root>
                            <label
                                htmlFor={`${field.id}-${option}`}
                                className="pl-[15px] text-[15px] leading-none"
                            >
                                {option}
                            </label>
                        </div>
                    )}
                />
            ))}
        </div>
    );
}

Feel free to open a discussion if you have any issue using the new APIs. We will deprecate useInputControl and remove the unstable prefix once we are confident with the new APIs.

Improvements

  • Improved type inference with nested discriminated union (#459)
  • Fixed an issue with zod not coercing the value of a multi select correctly (#447)
  • Fixed an issue with react devtool inspecting the state of the useForm hook.
  • Fixed an issue with file input never marked as dirty (#457)
  • Fixed several typos on the docs. Thanks to @ngbrown (#449) and @Kota-Yamaguchi (#463)

New Contributors

Full Changelog: v1.0.1...v1.0.2

v1.0.1

07 Feb 23:57
Compare
Choose a tag to compare

Improvements

  • The useInputControl hook should now populate initial value correctly with multiple select support.
  • Fixed an issue with the conformZodMessage.VALIDATION_SKIPPED not working as expected.
  • Conform no longer requires the form element to be available when doing any updates.
  • form.update() will now serialize the value and populated on the input properly
  • The useForm hook should now infer the bigint type correctly by @lifeiscontent
  • New Radix UI example by @marilari88
  • Fixed several issues on the documentation including typos and links by @diurivj, @marilari88, @narutosstudent, @jsparkdev, @Forus-Spec and @aust1nz

New Contributors

Full Changelog: v1.0.0...v1.0.1

v1.0.0

31 Jan 21:11
Compare
Choose a tag to compare

If you are looking into upgrading to v1, checkout the migration guide here.

Thank you everyone for your support on this project ❤️

v1.0.0-rc.1

27 Jan 21:32
Compare
Choose a tag to compare
v1.0.0-rc.1 Pre-release
Pre-release

This release candidate lands a few improvements from v1.0.0-rc.0

What's changed

  • Fixed an issue with inputs on nested list being reset when the list is modified.
  • Calling event.preventDefault() within the onSubmit handler should now prevent form submission properly
  • Added undefined as an accepted value for lastResult, for improved compatibility with TypeScript's exactOptionalPropertyTypes by @aaronadamsCA.
  • Removed image as an accepted value for input type by @aaronadamsCA.
  • All form controls are now triggered with the native requestSubmit API if it is available

Full Changelog: v1.0.0-rc.0...v1.0.0-rc.1

v1.0.0-rc.0

22 Jan 22:52
Compare
Choose a tag to compare
v1.0.0-rc.0 Pre-release
Pre-release

This release candidate is a complete rewrite of the library.

You can find the update remix example at https://stackblitz.com/github/edmundhung/conform/tree/v1.0.0-rc.0/examples/remix
or try it out locally using the following command:

npm install @conform-to/react@next @conform-to/zod@next

Breaking Changes

  • The minimum react version supported is now React 18

  • All conform helpers are renamed.

    • conform.input -> getInputProps
    • conform.select -> getSelectProps
    • conform.textarea -> getTextareaProps
    • conform.fieldset -> getFieldsetProps
    • conform.collection -> getCollectionProps
  • The type option on getInputProps is now required.

function Example() {
  return <input {...getInputProps(fields.title, { type: 'text' })} />;
}
  • form.props is removed. You can use the helper getFormProps() instead.
import { getFormProps } from '@conform-to/react';

function Example() {
  const [form] = useForm();

  return <form {...getFormProps(form)} />;
}
  • conform.INTENT is removed. If you need to setup an intent button, please use the name "intent" or anything you preferred.

  • You will find conform.VALIDATION_UNDEFINED and conform.VALIDATION_SKIPPED on our zod integration (@conform-to/zod) instead.

    • conform.VALIDATION_UNDEFINED -> conformZodMessage.VALIDATION_UNDEFINED
    • conform.VALIDATION_SKIPPED -> conformZodMessage.VALIDATION_SKIPPED.
  • The parse helper on @conform-to/zod is now called parseWithZod with getFieldsetConstraint renamed to getZodConstraint

  • The parse helper on @conform-to/yup is now called parseWithYup with getFieldsetConstraint renamed to getYupConstraint

  • Both useFieldset and useFieldList hooks are removed. You can now use meta.getFieldset() or meta.getFieldList() instead.

function Example() {
  const [form, fields] = useForm();

  // Instead of `useFieldset(form.ref, fields.address)`, it is now:
  const address = fields.address.getFieldset();

  // Instead of `useFieldList(form.ref, fields.tasks)`, it is now:
  const tasks = fields.tasks.getFieldList();

  return (
    <form>
      <ul>
        {tasks.map((task) => {
          // It is no longer necessary to define an addtional component
          // As you can access the fieldset directly
          const taskFields = task.getFieldset();

          return <li key={task.key}>{/* ... */}</li>;
        })}
      </ul>
    </form>
  );
}

Improved submission handling

We have redesigned the submission object received after parsing the formdata to simplify the setup with a new reply API for you to set additional errors or reset the form.

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  const submission = parse(formData, { schema });

  /**
   * The submission status could be either "success", "error" or undefined
   * If the status is undefined, it means that the submission is not ready (i.e. `intent` is not `submit`)
   */
  if (submission.status !== 'success') {
    return json(submission.reply(), {
      // You can also use the status to determine the HTTP status code
      status: submission.status === 'error' ? 400 : 200,
    });
  }

  const result = await save(submission.value);

  if (!result.successful) {
    return json(
      submission.reply({
        // You can also pass additional error to the `reply` method
        formError: ["Submission failed"],
        fieldError: {
          address: ["Address is invalid"],
        },

        // or avoid sending the the field value back to client by specifying the field names
        hideFields: ['password'],
      })
    );
  } 

  // Reply the submission with `resetForm` option
  return json(submission.reply({ resetForm: true }));
}

export default function Example() {
  const lastResult = useActionData<typeof action>();
  const [form, fields] = useForm({
    // `lastSubmission` is renamed to `lastResult` to avoid confusion
    lastResult,
  });

  // We can now find out the status of the submission from the form metadata as well 
  console.log(form.status); // "success", "error" or undefined
}

Simplified integration with the useInputControl hook

The useInputEvent hook is replaced by the useInputControl hook with some new features.

  • There is no need to provide a ref of the inner input element anymore. It looks up the input element from the DOM and will insert one for you if it is not found.

  • You can now use control.value to integrate a custom input as a controlled input and update the value state through control.change(value). The value will also be reset when a form reset happens

import { useInputControl } from '@conform-to/react';
import { CustomSelect } from './some-ui-library';

function Example() {
  const [form, fields] = useForm();
  const control = useInputControl(fields.title);

  return (
    <CustomSelect
      name={fields.title.name}
      value={control.value}
      onChange={(e) => control.change(e.target.value)}
      onFocus={control.focus}
      onBlur={control.blur}
    />
  );
}

Refined intent button setup

  • Both validate and list exports are removed in favor of setting up through the form metadata object.
    • validate -> form.validate
    • list.insert -> form.insert
    • list.remove -> form.remove
    • list.reorder -> form.reorder
    • list.replace -> form.update
function Example() {
  const [form, fields] = useForm();
  const tasks = fields.tasks.getFieldList();

  return (
    <form>
      <ul>
        {tasks.map((task) => {
          return <li key={task.key}>{/* ... */}</li>;
        })}
      </ul>
      <button {...form.insert.getButtonProps({ name: fields.tasks.name })}>
        Add (Declarative API)
      </button>
            <button onClick={() => form.insert({ name: fields.tasks.name })}>
        Add (Imperative API)
            </button>
    </form>
  );
}
  • You can now reset a form with form.reset or update any field value with form.update

Form Context

By setting up a react context with the <FormProvider />, we will now be able to subscribe to the form metadata using the useField() hook. This not only avoids prop drilling but also prevent unneccessary re-renders by tracking the usage of indivudal metadata through a proxy and only rerender it if the relevant metadata is changed.

The <FormProvider /> can also be nesteded with different form context and Conform will look up the closest form context unless a formId is provided.

import { type FieldName, FormProvider, useForm, useField } from '@conform-to/react';

function Example() {
  const [form, fields] = useForm({ ... });

  return (
    <FormProvider context={form.context}>
      <form>
        <AddressFieldset name={fields.address.name} />
      </form>
    </FormProvider>
  );
}

// The `FieldName<Schema>` type is basically a string with additional type information encoded
type AddressFieldsetProps = {
  name: FieldName<Address>
}

export function AddressFieldset({ name }: AddressFieldsetProps) {
  const [meta] = useField(name);
  const address = meta.getFieldset();

  // ...
}

If you want to create a custom input component, it is now possible too!

import { type FieldName, FormProvider, useForm, useField, getInputProps } from '@conform-to/react';

function Example() {
  const [form, fields] = useForm({ ... });

  return (
    <FormProvider context={form.context}>
      <form>
        <CustomInput name={fields.title.name} />
      </form>
    </FormProvider>
  );
}

type InputProps = {
  name: FieldName<string>
}

// Make your own custom input component!
function CustomInput({ name }: InputProps) {
  const [
    meta,
    form, // You can also access the form metadata directly
  ] = useField(name);

  return (
    <input {...getInputProps(meta)} />
  );
}

Similarly, you can access the form metadata on any component using the useFormMetadata() hook:

import { type FormId, FormProvider, useForm, getFormProps } from '@conform-to/react';

function Example() {
  const [form, fields] = useForm({ ... });

  return (
    <FormProvider context={form.context}>
      <CustomForm id={form.id}>
        {/* ... */}
      </CustomForm>
    </FormProvider>
  );
}

function CustomForm({ id, children }: { id: FormId; children: ReactNode }) {
  const form = useFormMetadata(id);

  return (
    <form {...getFormProps(form)}>
      {children}
    </form>
  );
}

v0.9.1

20 Nov 19:53
Compare
Choose a tag to compare

Improvements

New Contributors

Full Changelog: v0.9.0...v0.9.1

v0.9.0

10 Sep 21:18
Compare
Choose a tag to compare

Breaking Change

  • Restricted the type of submission.payload to Record<string, unknown> instead of Record<string, any> to unblock usages on Remix v2-pre by @kentcdodds in #272

Full Changelog: v0.8.2...v0.9.0

v0.8.2

07 Sep 18:25
Compare
Choose a tag to compare

Improvements

  • Fixed an issue with the new list insert intent triggering an error on the client when default value is set by @albert-schilling (#286)

Full Changelog: v0.8.1...v0.8.2

v0.8.1

03 Sep 13:59
Compare
Choose a tag to compare

Improvements

  • Introduced a params option on the refine helper for better error map support (#264) by @jansedlon
refine(ctx, {
  when: intent === 'submit' || intent === 'validate/field',
  validate: () => validate(...),
  params: {
    i18n: "key"
  }
})
  • Added a insert list intent for inserting a new row to a list at a given index. If no index is given, then the element will be appended at the end of the list. Both append and prepend list intent are deprecated and will be removed on v1. (#270) by @albert-schilling
<button {...list.insert('name', { index, defaultValue })}>Insert</button>
  • Fixed an issue with zod returning only the first error on zod v3.22 caused by a preprocess bug upstream. (#283)
  • Fixed a bug with zod default not working properly due to empty value not stripped. (#282)
  • Improved zod parse helper handling on environment with no File constructor defined. (#281)

New Contributors

Full Changelog: v0.8.0...v0.8.1

v0.8.0

10 Aug 19:18
Compare
Choose a tag to compare

Breaking Changes

  • Conform does automatic type coercion with Zod now. The stripEmptyValue option is removed as empty values are always stripped. (#227, #230, #236, #244)
import { z } from 'zod';

// Before: Use .min(1) to mark a field as required
const required = z.string().min(1, 'Field is required');

// Now: It's required by default and use .optional() to mark it as optional
const required = z.string({ required_error: 'Field is required' });
const optional = z.string().optional();

// Before: Manualy transform the value to the desired type
const numberInput = z.string().transform((val) => Number(value));
// or use preprocess
const checkbox = z.preprocess(value => value === 'on', z.boolean());;

// Now: Conform does type coercion for you
const numberInput = z.number();
// Including checkbox. (Note: boolean coercion only works with default value 'on')
const checkbox = z.boolean().optional();

// You can continue transform / preprocess yourself
// e.g. checkbox with custom value "yes"
const checkbox = z.string().transform(value => value === 'yes');
  • The acceptMultipleErrors option is removed since Conform handles multiple errors by default. There is no impact on the validation behaviour. If you were using this option, you can safely remove it. (#228)
const submission = parse(formData, {
    // This option can now be removed 
    acceptMultipleErrors() {
        // ...   
    }
});
  • Conform now enforce an array for all errors. If you were setting errors manually, like parsing form data with custom resolver or setting additional errors to the submission object, you need to wrap it in an array. (#228)
export async function action({ request }) {
    const formData = await request.formData();
    const submission = parse(formData, {
        // ...
    });


    if (submission.intent === 'submit' || !submission.value) {
        return json(submission);
    }

    if (/* some additional checks */) {
        return json({
            ...submission,
            error: {
                // Pass an array instead of a string 
                '': ['Something went wrong']
            }
        }) 
    }
}
  • The default value of useFieldList is now an empty list instead of an array with one item. (#245)
function Example() {
  const [form, fields] = useForm({
    // You can set the default value to an array with one item if you want
    defaultValue: {
      tasks: [''],
    },
  })
  const tasks = useFieldList(fields.tasks);

  return (
    <form>
      <div>
        {tasks.map((task, index) => (
          <div key={task.key}>
            {/* ... */}
          </div>
        ))} 
      </div>
    </form>
  );
}
  • The ariaAttributes option on the conform helpers is now enabled by default (#226)

Improvements

  • You can now setup a checkbox group using the conform collection helper. Check the new guide for details. (#201)
import { conform, useForm } from '@conform-to/react';
import { parse } from '@conform-to/zod';
import { z } from 'zod';

const schema = z.object({
  answer: z
    .string()
    .array()
    .nonEmpty('At least one answer is required'),
});

function Example() {
  const [form, { answer }] = useForm({
    onValidate({ formData }) {
      return parse(formData, { schema });
    },
  });

  return (
    <form {...form.props}>
      <fieldset>
        <lengend>Please select the correct answers</legend>
        {conform
          .collection(answer, {
            type: 'checkbox',
            options: ['a', 'b', 'c', 'd'],
          })
          .map((props, index) => (
            <div key={index}>
              <label>{props.value}</label>
              <input {...props} />
            </div>
          )))}
        <div>{answer.error}</div>
      </legend>
      <button>Submit</button>
    </form>
  );
}

New Contributors

Full Changelog: v0.7.4...v0.8.0