Thursday, June 26, 2025

Strongly-typed MUI Form Helper

form.ts
import React from 'react';

type UnifiedChangeEvent =
  | React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  | React.ChangeEvent<{ name?: string; value: unknown }>
  | (Event & { target: { name: string; value: unknown } });

export type ErrorObject<T> = Partial<Record<keyof T, string | boolean>>;

function handleInputChange<T>(
  values: T,
  setValues: (o: T) => void,
  fieldName: keyof T
) {
  return {
    onChange(event: UnifiedChangeEvent) {
      const { name, value } = event.target;
      setValues({ ...values, [name!]: value });
    },
    name: fieldName,
    value: values[fieldName],
  };
}

export function setupInputChange<T>(values: T, setValues: (o: T) => void) {
  return (fieldName: keyof T) =>
    handleInputChange(values, setValues, fieldName);
}

function handleErrorChange<T>(errors: T, fieldName: keyof T) {
  return errors[fieldName] && { error: true, helperText: errors[fieldName] };
}

export function setupErrorChange<T>(errors: T) {
  return (fieldName: keyof T) => handleErrorChange(errors, fieldName);
}

export function isValid<T>(errors: ErrorObject<T>): boolean {
  for (const v of Object.values(errors)) {
    if (v) {
      return false;
    }
  }
  return true;
}

export function hasErrors<T>(errors: ErrorObject<T>) {
  return !isValid(errors);
}

export function setupInputProps<T, U>(
  values: T,
  setValues: (o: T) => void,
  errors: ErrorObject<U>,
  setErrors: (o: ErrorObject<U>) => void
) {
  return {
    handleInputProps: (fieldName: keyof T & keyof U) => ({
      ...handleInputChange(values, setValues, fieldName),
      ...handleErrorChange(errors, fieldName),
    }),
    checkValidators: (errors: ErrorObject<U>) => {
      setErrors(errors);
      return isValid(errors);
    },
  };
}
Example use:
import { FormEvent, useState } from 'react';

import {
  Button,
  TextField,
} from '@mui/material';

import './App.css';

import { setupInputProps, type ErrorObject } from './utils/form';

class DonationCandidateDto {
  donationCandidateId = 0;
  fullname = '';
  mobile = '';
  email = '';
  age = 0;
  bloodGroup = '';
  address = '';
  active = false;
}

const initialFieldValues: DonationCandidateDto = {
  donationCandidateId: 0,
  fullname: '',
  mobile: '',
  email: '',
  age: 0,
  bloodGroup: '',
  address: '',
  active: false,
};

type FormType = typeof initialFieldValues;

// out-of-band validations
type OtherStates = {
  areZombiesInLab: boolean;
  day?: number;
};

function App() {
  const [values, setValues] = useState(structuredClone(initialFieldValues));
  const [errors, setErrors] = useState<ErrorObject<FormType & OtherStates>>({});

  const { handleInputProps, checkValidators } = setupInputProps(
    values,
    setValues,
    errors,
    setErrors
  );

  return (
    <div>
      <h1>Hello world</h1>
      <form autoComplete="off" noValidate onSubmit={handleSubmit}>
        <div>
          <TextField
            label="Full name"
            required={true}
            {...handleInputProps('fullname')}
          />
        </div>
        <br />
        <div>
          <TextField label="Age" {...handleInputProps('age')} />
        </div>

        <div>
          {errors.areZombiesInLab && (
            <div style={{ color: 'red' }}>{errors.areZombiesInLab}</div>
          )}
        </div>

        <br />
        <div>
          <Button color="primary" type="submit" variant="contained">
            Submit
          </Button>
          &nbsp;&nbsp;&nbsp;
          <Button type="submit" color="inherit" variant="contained">
            Reset
          </Button>
        </div>
      </form>
      <hr />
      {/* {JSON.stringify(values)} */}
    </div>
  );

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const isValid = checkValidators({
      fullname: !values.fullname && 'Must have full name',
      age: !(values.age >= 18) && 'Must be an adult',
      // areZombiesInLab: true && 'Zombies in lab',
    });

    if (!isValid) {
      return;
    }

    // POST/PUT here
  }
}

export default App;
If the name is not existing from the DTO, it will not compile:













No comments:

Post a Comment