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.
Sandro Maglione
Contact me523 words
・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 name
s 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 removename: string
from the default propsName extends string
to provide the generic type parameter
NoInfer
makes passing the generic parameter required, otherwise it will default tonever
and passingname
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
andFormData
must reference the sameFormName
type.
We create a typed utility function on top of formData.get
making it type-safe:
- Pass both
FormData
and thename
key to extract name
must conform to the generic type parameter (required usingNoInfer
)
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 rulesexec
gets the encoded data and executes anasync
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 thename
of the inputs in the form.R
is made required by usingnever
andNoInfer
. 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 astring
age
is decoded fromstring
tonumber
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>
);
}