Make FormData and input names type-safe in React

useActionState gives access to FormData, keeping inputs uncontrolled and extracting values by name. But is it all type-safe? Well, no. But we can fix it.

Author Sandro Maglione

Sandro Maglione

Contact me

The new useActionState hook in React 19 saves you from a mess of useState and impromptu manual loading states.

It reduces all to a single hook that includes sync/async request and pending state. The form uses uncontrolled components references by name using FormData:

export default function Page() {
  const [_, action, pending] = useActionState((_: unknown, formData: FormData) => {
    const firstName = formData.get("firstName");
    const age = formData.get("age");
  }, null);
  return (
    <form action={action}>
      <input type="text" name="firstName" />
      <input type="number" name="age" />
      <button type="submit" disabled={pending}>Submit</button>
    </form>
  );
}

Clean and simple. But! Can you see the problem in the snippet above? 🤔

If you have an eye for type-safety your senses should be on extreme alert. Take a closer look:

export default function Page() {
  const [_, action, pending] = useActionState((_: unknown, formData: FormData) => {
    const firstName = formData.get("firstName");
    const age = formData.get("age");
  }, null);
  return (
    <form action={action}>
      <input type="text" name="firstName" />
      <input type="number" name="age" />
      <button type="submit" disabled={pending}>Submit</button>
    </form>
  );
}

formData.get accepts any string. There is no type-safe connection with the names in the form. It will soon come the day when:

export default function Page() {
  const [_, action, pending] = useActionState((_: unknown, formData: FormData) => {
    const firstName = formData.get("first-name"); // 🫠
    const age = formData.get("age");
  }, null);
  return (
    <form action={action}>
      <input type="text" name="firstName" />
      <input type="number" name="age" />
      <button type="submit" disabled={pending}>Submit</button>
    </form>
  );
}

Imagine if the request is located in another file (as it usually is). Good luck finding the issue (typo) 🫠

Guess what? This can be fixed! Let's see how 🙌

Typing input name

Let's start from input and its name.

By default, name can be any string. We need to narrow it down to a custom union of values.

We do this by implementing a SaveInput component that swaps the default name with a custom generic parameter:

  • Omit to remove name: string from the default props
  • Name extends string to provide the generic type parameter

NoInfer makes passing the generic parameter required, otherwise it will default to never and passing name will error.

type NativeProps = React.DetailedHTMLProps<
  React.InputHTMLAttributes<HTMLInputElement>,
  HTMLInputElement
>;

function SaveInput<Name extends string = never>(
  props: Omit<NativeProps, "name"> & {
    name: NoInfer<Name>;
  }
) {
  return <input {...props} />;
}

Now inside the form we define a FormName type that restricts the name for each SaveInput:

With TypeScript and React is possible to provide generic parameters to components: <SaveInput<FormName> />

type FormName = "firstName" | "age";

export default function Page() {
  const [_, action] = useActionState((_: unknown, formData: FormData) => {
    const firstName = formData.get("firstName");
    const age = formData.get("age");
  }, null);
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}

We made name type-safe for each input. This is the first step.

type FormName = "firstName" | "age";

export default function Page() {
  const [_, action] = useActionState((_: unknown, formData: FormData) => {
    const firstName = formData.get("firstName");
    const age = formData.get("age");
  }, null);
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="lastName" /> {/* ⛔️ `lastName` type error */}
      <SaveInput type="number" name="age" />  {/* ⛔️ Generic parameter is required */}
      <button type="submit">Submit</button>
    </form>
  );
}

Type safe FormData

A similar strategy allows propagating type-safety also to FormData.

The key requirement is that both input and FormData must reference the same FormName type.

We create a typed utility function on top of formData.get making it type-safe:

  • Pass both FormData and the name key to extract
  • name must conform to the generic type parameter (required using NoInfer)
const get = <Name extends string = never>(
  formData: FormData,
  name: NoInfer<Name>
) => formData.get(name);

We can finally connect all together using FormName:

type FormName = "firstName" | "age";

const get = <Name extends string = never>(
  formData: FormData,
  name: NoInfer<Name>
) => formData.get(name);

export default function Page() {
  const [_, action] = useActionState((_: unknown, formData: FormData) => {
    const firstName = get<FormName>(formData, "firstName");
    const age = get<FormName>(formData, "age");
  }, null);
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}

Done! FormName links each input with FormData, so that there is no more the risk for typos ✨

type FormName = "firstName" | "age";

const get = <Name extends string = never>(
  formData: FormData,
  name: NoInfer<Name>
) => formData.get(name);

export default function Page() {
  const [_, action] = useActionState((_: unknown, formData: FormData) => {
    const firstName = get<FormName>(formData, "lastName"); // ⛔️ `lastName` invalid
    const age = get(formData, "age"); // ⛔️ Generic type required
  }, null);
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}

Extra: Schema validation

You may (and should) want to extend this with some sort of schema validation. Let's see how using Schema from effect.

First, instead of calling get for each input, we instead extract all entries inside a Record:

const formDataToRecord = (formData: FormData): Record<string, string> => {
  const record: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (typeof value === "string") {
      record[key] = value;
    }
  }
  return record;
};

Schema is used to validate (decode) the input and execute a request with the encoded data:

  • schema decodes the input to make sure it conforms to some validation rules
  • exec gets the encoded data and executes an async request (e.g. API request)
const execute =
  <I, A, T>(schema: Schema.Schema<A, I>, exec: (values: I) => Promise<T>) => // ...

Since values extracted from FormData are all string (formDataToRecord returns Record<string, string>), we use another Schema to decode from string to custom values:

The generic type R represents the name of the inputs in the form. R is made required by using never and NoInfer. This makes sure that we don't forget to provide the parameter (type-safety).

const execute =
  <I, A, T>(schema: Schema.Schema<A, I>, exec: (values: I) => Promise<T>) =>
  <R extends string = never>(
    source: Schema.Schema<I, Record<NoInfer<R>, string>>
  ) => // ...

execute then returns a function that accepts FormData, like we did for get before:

const execute =
  <I, A, T>(schema: Schema.Schema<A, I>, exec: (values: I) => Promise<T>) =>
  <R extends string = never>(
    source: Schema.Schema<I, Record<NoInfer<R>, string>>
  ) =>
  (formData: FormData) => // ...

The implementation then looks as follows:

class ApiError extends Data.TaggedError("ApiError")<{
  cause: unknown;
}> {}

const formDataToRecord = (formData: FormData): Record<string, string> => {
  const record: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (typeof value === "string") {
      record[key] = value;
    }
  }
  return record;
};

const execute =
  <I, A, T>(schema: Schema.Schema<A, I>, exec: (values: I) => Promise<T>) =>
  <R extends string = never>(
    source: Schema.Schema<I, Record<NoInfer<R>, string>>
  ) =>
  (formData: FormData) =>
    Effect.runPromise(
      Schema.decodeUnknown(source)(formDataToRecord(formData)).pipe(
        Effect.flatMap(Schema.decode(schema)),
        Effect.flatMap(Schema.encode(schema)),
        Effect.flatMap((values) =>
          Effect.tryPromise({
            try: () => exec(values),
            catch: (error) => new ApiError({ cause: error }),
          })
        )
      )
    );

Define type-safe API

Using execute we first define the validation schema and the request:

const insertUser = execute(
  Schema.Struct({
    firstName: Schema.NonEmptyString, // 👈 Extra validation
    age: Schema.Number.pipe(Schema.between(18, 65)), // 👈 Extra validation
  }),
  ({ firstName, age }) => // API request
);

Calling insertUser requires to provide another schema that decodes from string:

  • firstName remains a string
  • age is decoded from string to number

Finally, we pass FormData to execute the request:

We are also required to specify FormName as generic type parameter (type-safety).

type FormName = "firstName" | "age";

const insertUser = execute(
  Schema.Struct({
    firstName: Schema.String,
    age: Schema.NumberFromString
  })
);

export default function Page() {
  const [_, action] = useActionState(
    (_: unknown, formData: FormData) =>
      insertUser<FormName>(
        Schema.Struct({
          firstName: Schema.String,
          age: Schema.NumberFromString, // 👈 From `string` to `number`
        })
      )(formData),
    null
  );
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}

Back to type-safety again!

Just like before, any typo in name or Schema will cause an error:

type FormName = "firstName" | "age";

const insertUser = execute(
  Schema.Struct({
    lastName: Schema.String, // ⛔️ Error `lastName` not included in `FormName`
    age: Schema.Number.pipe(Schema.between(18, 65)),
  })
);

export default function Page() {
  const [_, action] = useActionState(
    (_: unknown, formData: FormData) =>
      insertUser( // ⛔️ Missing required type parameter
        Schema.Struct({
          firstName: Schema.String,
          age: Schema.Number, // ⛔️ Type 'number' is not assignable to type 'string'
        })
      )(formData),
    null
  );
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}

Below the full code copy-paste ready:

import { Data, Effect, Schema } from "effect";
import { useActionState } from "react";

type NativeProps = React.DetailedHTMLProps<
  React.InputHTMLAttributes<HTMLInputElement>,
  HTMLInputElement
>;

function SaveInput<Name extends string = never>(
  props: Omit<NativeProps, "name"> & {
    name: Name;
  }
) {
  return <input {...props} />;
}

class ApiError extends Data.TaggedError("ApiError")<{
  cause: unknown;
}> {}

const formDataToRecord = (formData: FormData): Record<string, string> => {
  const record: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (typeof value === "string") {
      record[key] = value;
    }
  }
  return record;
};

const execute =
  <I, A, T>(schema: Schema.Schema<A, I>, exec: (values: I) => Promise<T>) =>
  <R extends string = never>(
    source: Schema.Schema<I, Record<NoInfer<R>, string>>
  ) =>
  (formData: FormData) =>
    Effect.runPromise(
      Schema.decodeUnknown(source)(formDataToRecord(formData)).pipe(
        Effect.flatMap(Schema.decode(schema)),
        Effect.flatMap(Schema.encode(schema)),
        Effect.flatMap((values) =>
          Effect.tryPromise({
            try: () => exec(values),
            catch: (error) => new ApiError({ cause: error }),
          })
        )
      )
    );

const insertUser = execute(
  Schema.Struct({
    firstName: Schema.NonEmptyString,
    age: Schema.Number.pipe(Schema.between(18, 65)),
  }),
  async ({ firstName, age }) => 0 // API request
);

type FormName = "firstName" | "age";

export default function Page() {
  const [_, action] = useActionState(
    (_: unknown, formData: FormData) =>
      insertUser<FormName>(
        Schema.Struct({
          firstName: Schema.String,
          age: Schema.NumberFromString,
        })
      )(formData),
    null
  );
  return (
    <form action={action}>
      <SaveInput<FormName> type="text" name="firstName" />
      <SaveInput<FormName> type="number" name="age" />
      <button type="submit">Submit</button>
    </form>
  );
}