Skip to main content

Controlled components

@per-form/react use uncontrolled components for performance reason but having controlled component can still be useful in some cases:

  • you want to react to each key stroke in a text field (filtering, searching...etc.)
  • you want to have dynamic things depending on form values
  • you want to use an UI library that was design with controlled components (like Material UI...etc.)
  • ...etc.
tip

You can sometimes have the same result using the watch function instead of using controlled components. See the watch guide for more information.

Simple controlled component

Let's imagine wa want to synchronize our text input to be able to do some filtering on a list.

Then you can just declare a state and use value (or checked for checkbox and radio buttons) and onChange as you would normally do.

But if you want to reset the form, you also need to update the state of the component on the reset event.

For that you can use the onReset parameter (or the onReset handler, see the reset guide for more information):

0
value =
import { type ChangeEvent, type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

export default function Demo(props: IProps) {
const [value, setValue] = useState('');

function handleChange(event: ChangeEvent<HTMLInputElement>) {
setValue(event.target.value);
}

function handleReset() {
setValue('');
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps } = useForm({
...props,
onReset: handleReset,
onSubmit: handleSubmit,
});

return (
<form {...formProps}>
<input name="text" onChange={handleChange} required value={value} />
{errors.all.text && <div className="error">{errors.all.text}</div>}
<div>value = {value}</div>
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
note

@per-form/react do not manage the state of your form, you are free to use state if you want, but it's your responsibility to manage it correctly.

Controlled component with type casting

Controlled components get their value from the onChange handler.

You can cast yourself the transformer function on event.target.value but @per-form/react gives you a way to this automatically for you.

In that case you can use the onChange handler to wrap your update state function and it will receive the transformed value :

0
value = 0 (number)
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

const defaultValues = { count: 0 };
const transformers = { count: Number };

export default function Demo(props: IProps) {
const [value, setValue] = useState(0);

function handleReset() {
setValue(0);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps, onChange } = useForm({
...props,
defaultValues,
onReset: handleReset,
onSubmit: handleSubmit,
transformers,
});

return (
<form {...formProps}>
<input
name="count"
onChange={onChange(setValue)}
required
type="number"
value={value}
/>
{errors.all.count && <div className="error">{errors.all.count}</div>}
<div>
value = {value} ({typeof value})
</div>
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
info

If you want to have default values, like for the type casting example, you need to provide them using the defaultValues parameter or the defaultValue props.

UI libraries

Using the onChange handler

The onChange handler you use for updating the state can automatically infer the component name from the ChangeEvent object returned by react.

But in some scenario UI libraries sometimes directly send the value in the onChange callback instead of an event object.

In that case you can provide the name yourself by using second parameter of the onChange handler:

0
import { DatePicker } from '@mui/x-date-pickers';
import type { Dayjs } from 'dayjs';
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

export default function Demo(props: IProps) {
const [value, setValue] = useState<Dayjs | null>(null);

function handleReset() {
setValue(null);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps, onChange } = useForm({
...props,
onReset: handleReset,
onSubmit: handleSubmit,
});

return (
<form {...formProps}>
<DatePicker
name="mui"
onChange={onChange(setValue, { name: 'mui' })}
slotProps={{ textField: { required: true } }}
value={value}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
info

Remember to use the onReset parameter or the onReset handler to effectively reset the state if you need to.

note

The value displayed under the form when you submit is the serialization of the Dayjs value.

But the value you get in your onSubmit callback is a real Dayjs value. Check the console !

Manual errors

With the onError handler

Some components you may use manage validation internally, in that case you can use the onError handler to forward the error to @per-form/react.

The onError handler accept one argument, the name of the form field, and return a new function that accept the error string as argument (or null if there is no error) :

0
import { DatePicker } from '@mui/x-date-pickers';
import dayjs, { type Dayjs } from 'dayjs';
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

export default function Demo(props: IProps) {
const [value, setValue] = useState<Dayjs | null>(null);

function handleReset() {
setValue(null);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps, onChange, onError } = useForm({
...props,
onReset: handleReset,
onSubmit: handleSubmit,
});

return (
<form {...formProps}>
<DatePicker
minDate={dayjs()}
name="mui"
onChange={onChange(setValue, { name: 'mui' })}
onError={onError('mui')}
slotProps={{ textField: { required: true } }}
value={value}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
info

Try to manually enter a date like 01/01/2024 to see the error.

tip

You can customize the error message with something like onError={(error) => onError('mui')(messages[error])} for example.

But you can also customize the error message using @per-form/react messages parameter.

Check the chapter about messages and i18n for more information.

With the onChange handler

Sometimes the error is given using the onChange callback.

In that case you can use the second parameter of the onChange handler and set the getError property with a function that will be responsible to return the error.

The getError will get the value as first argument (potentially transformed if transformers parameter is used) and will get all other arguments from the original onChange callback:

0
import { DatePicker } from '@mui/x-date-pickers';
import dayjs, { type Dayjs } from 'dayjs';
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

export default function Demo(props: IProps) {
const [value, setValue] = useState<Dayjs | null>(null);

function handleReset() {
setValue(null);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

function getError(_value: Dayjs, error: { validationError: string | null }) {
return error.validationError;
}

const { errors, formProps, onChange } = useForm({
...props,
onReset: handleReset,
onSubmit: handleSubmit,
});

return (
<form {...formProps}>
<DatePicker
minDate={dayjs()}
name="mui"
onChange={onChange<Dayjs, [{ validationError: string | null }]>(
setValue,
{ getError, name: 'mui' },
)}
slotProps={{ textField: { required: true } }}
value={value}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}

Default values

Use the defaultValues parameter to initialize the default values.

Of course, for controlled components you should initialize your state with the same value:

0
import { DatePicker } from '@mui/x-date-pickers';
import dayjs, { type Dayjs } from 'dayjs';
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

const defaultValues = { mui: dayjs() };

export default function Demo(props: IProps) {
const [value, setValue] = useState<Dayjs>(defaultValues.mui);

function handleReset() {
setValue(defaultValues.mui);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps, onChange } = useForm({
...props,
defaultValues,
onReset: handleReset,
onSubmit: handleSubmit,
});

return (
<form {...formProps}>
<DatePicker
name="mui"
onChange={onChange(setValue, { name: 'mui' })}
slotProps={{ textField: { required: true } }}
value={value}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}

Validators and onChange event opt-out

When using some controlled component with custom validators you can sometimes receive weird values.

You probably expect to receive a Dayjs value in the validator, but in such scenario you will also get badly formatted date strings like '01/MM/YYYY' that can break you validator (because when you manually enter a date, it will trigger the native onChange event that will read the input. ).

You have two options:

  1. Managing the case in your validator.
  2. Or opting-out for the native onChange event for this field (and only rely on the onChange props).

You can opt-out using the onChangeOptOut parameter that accept a string, a list of strings or 'all' to opt-out for all fields.

Opting-out won't be enough because validation is also triggered during initialization, so your validator will still be called with some string (in this example, empty string ""), but you can also fix that problem by providing a default value:

0
import { DatePicker } from '@mui/x-date-pickers';
import { type Dayjs } from 'dayjs';
import { type FormEvent, useState } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

const defaultValues = { mui: null };
const validators = {
mui: (values: IFormValues) => {
const date = values.mui as Dayjs;
return date?.date() > 15 ? '' : 'Choose a date after the 15th.';
},
};

export default function Demo(props: IProps) {
const [value, setValue] = useState<Dayjs | null>(null);

function handleReset() {
setValue(null);
}

function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps, onChange } = useForm({
...props,
defaultValues,
onChangeOptOut: 'mui',
onReset: handleReset,
onSubmit: handleSubmit,
validators,
});

return (
<form {...formProps}>
<DatePicker
name="mui"
onChange={onChange(setValue, { name: 'mui' })}
slotProps={{ textField: { required: true } }}
value={value}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
note

Select a date before the 15th of each month to see the error.

Uncontrolled

You can still use such components in an uncontrolled way.

In that case you won't receive a Dayjs object as value, but you can use the transformers props for that converting purpose if you want.

0
import { DatePicker } from '@mui/x-date-pickers';
import dayjs from 'dayjs';
import { type FormEvent } from 'react';
import type { IProps } from '../types';
import { type IFormValues, useForm } from '@per-form/react';

const today = dayjs();
const transformers = { mui: (date: unknown) => dayjs(String(date)) };

export default function Demo(props: IProps) {
function handleSubmit(_e: FormEvent<HTMLFormElement>, values: IFormValues) {
console.log(values);
}

const { errors, formProps } = useForm({
...props,
onSubmit: handleSubmit,
transformers,
});

return (
<form {...formProps}>
<DatePicker
defaultValue={today}
name="mui"
slotProps={{
field: { clearable: true },
textField: { required: true },
}}
/>
{errors.all.mui && <div className="error">{errors.all.mui}</div>}
<div className="actions">
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</div>
</form>
);
}
note

The problem with that example is that we can't reset the <DatePicker> component value from outside (using the reset button).