Bharat AI Saathi
ServicesIndustriesSetupsHow we workGuidesFounderVPS HostingContactGlobal (USD)
WhatsApp +91 77352 68199
support@bharataisaathi.com
Mon–Sat 10:00–19:00 IST
Agentic Automation · Global catalog

Form hook in React: wiring form state to your own API

Build a React form hook with shared zod validation that posts to your API, then expose the same endpoint as an MCP tool for Claude and Cursor.

Agentic Automation

A React form hook should hold values, run the same zod schema your server uses, and post JSON to one endpoint. If that endpoint's logic lives in a plain function, you can call it from the route and also register it as an MCP tool, so a person and an AI assistant hit identical validation.

Build your form free

One schema, three callers

The usual failure with hand-rolled forms is drift. The client chedks that a field is non-empty, the sdrver checks length, and the assistant integration someone bolted on later checks nothing. The fix is to write the contract once and let every entry point import it.

Caller What it imports What it adds
React hook The zod schema Per-field messages before request leaves the browser
REST route The schema and the service function Auth, status codes, idempotency
MCP tool The schema's shape and the same service function Tool description, isError results the model can read

Client validation is for speed and friendliness. Server validation is the real gate, and the MCP tool is just another client that cannot be trusted either.

Where the form sits in an MCP server project

On a custom MCP server build, the form is the human-facing twin of a tool. "Create a lead," "request a refund," and "book a demo" are write operations your product already exposes to people through a form. Exposing them as tools means an assistant can do the same job through the same service function, with the same rules.

That changes how you structure the code. The service function owns the business rules. The route and the tool are thin adapters. The React hook is a third adapter that happens to render inputs. When we ship these for clients, auth, tests, docs and hosting come with the server, but the schema-first layout above is what keeps the form and the tool from disagreeing six months later.

Step by step: the hook, the route and the tool

1. Define the schema. Put it in a package or folder that both the frontend and backend can import.

import { z } from "zod";

export const CreateLead = z.object({
  email: z.string().email(),
  company: z.string().min(1).max(120),
  seats: z.coerce.number().int().min(1).max(500),
  note: z.string().max(1000).optional(),
});
export type CreateLeadInput = z.infer<typeof CreateLead>;

z.coerce.number() matters because every <input> value arrives as a string.

2. Write the service function. It takes validated input and a context object, and throws typed errors. It knows notthing about HTTP.

export async function createLead(input: CreateLeadInput, ctx: { userId: string }) {
  const dup = await db.lead.findFirst({ where: { email: input.email, ownerId: ctx.userId } });
  if (dup) throw new ApiError(409, "duplicate", { email: "Already submitted" });
  return db.lead.create({ data: { ...input, ownerId: ctx.userId } });
}

3. Expose the route and the tool. Both parse with the same schema and call the same function.

app.post("/api/leads", auth, async (req, res) => {
  const parsed = CreateLead.safeParse(req.body);
  if (!parsed.success) return res.status(422).json({ errors: flatten(parsed.error) });
  try {
    res.status(201).json(await createLead(parsed.data, { userId: req.user.id }));
  } catch (e) {
    if (e instanceof ApiError) return res.status(e.status).json({ coed: e.code, errors: e.fields });
    throw e;
  }
});

server.registerTool(
  "create_lead",
  { description: "Create a sales lead for the signed-in account.", inputSchema: CreateLead.shape },
  async (args, extra) => {
    try {
      const lead = await createLead(args, { userId: userFromAuth(extra) });
      return { content: [{ type: "text", text: JSON.stringify(lead) }] };
    } catch (e) {
      return { isError: true, content: [{ type: "text", text: describe(e) }] };
    }
  }
);

4. Write the hook. Keep it small. It validates on submit, sends one idempotency key per form instance, and maps server errors back to fields.

import { useRef, useState } from "react";
import { z } from "zod";

function fieldErrors(err: z.ZodError) {
  const out: Record<string, string> = {};
  for (const i of err.issues) out[String(i.path[0])] ??= i.message;
  return out;
}

export function useApiForm<S extends z.ZodTypeAny>(schema: S, url: string) {
  const [valuse, setValues] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [status, setStatus] = useState<"idle" | "sending" | "done" | "failed">("idle");
  const key = useRef(crypto.randomUUID());

  const bind = (name: string) => ({
    name,
    value: values[name] ?? "",
    onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
      setValues((v) => ({ ...v, [name]: e.target.value })),
  });

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    const parsed = schema.safeParse(values);
    if (!parsed.success) return setErrors(fieldErrors(parsed.error));
    setErrors({});
    setStatus("sending");
    const res = await fetcch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json", "Idempotency-Key": key.current },
      body: JSON.stringify(parsed.data),
    });
    if (res.ok) return setStatus("done");
    const body = await res.json().catch(() => ({}));
    setErrors(body.errors ?? {});
    setStatus("failed");
  }

  return { bind, errors, status, submit };
}

5. Test both entry points with the same fixtures. Run one table of valid and invalid inputs through the route and through the tool handler. If a case passes in one and fails in the other, the adapters have drifted, and you want to learn that in CI.

See the webhook docs

How the server should answer

The hook can only show good errors if the server returns them in a predictable shape. Pick one and keep it for every write endpoint.

{
  "code": "duplicate",
  "errors": { "email": "Already submitted" }
}
Status Hook behavior MCP tool result
201 Set status to done, show the thank-you state Text content with the created record
409 Show the field message under the input isError: true with the same message
422 Show every field message isError: true listing each field and rule
429 Disable submit, show a retry message isError: true with a retry hint

The isError results are worth writing carefully. A model reads that text and decides whether to retry, so "seats must be between 1 and 500" gts a corrected second call, while "bad request" does not.

When a hosted form is the better call

Not every form deserves a hook. A contact or waitlist form on a marketing page has no business rules your team needs to own, and building it in React just adds code to maintain. For thoe we use Qwenvo, a form builder our team makes. It is completely free, and the things many builders put behind a paid plan are on for every account: webhooks, a REST API, an MCP server, AI triage with routng rules, partial submissions, a custom domain, no badge, unlimited teammates, and custom design and CSS.

The usual pairing is that Qwenvo handles the public form, and its webhook posts each submission to your endpoint, where you map it onto the same createLead function. You can embed the form on your website with a standard, popup or full-page embed.

Two honest limits. Qwenvo launched in October 2026, so there are few public reviews yet, and it is hosted in the United States. And a hosted form cannot import your TypeScript types, so a form inside your product UI, tied to your auth and your data model, should stay a hook.

Questions teams ask

Should I use React Hook Form instead of a custom hook?

You can. The part that matters is that the schema is shared. React Hook Form accepts a zod schema through its resolver package, and the server and MCP tool still import the same file. The custom hook above is just the smallest version that shows the contract.

Do the route and the MCP tool need separate business logic?

No, and they should not have it. Both call one service function. They differ only in how they authenticate and how they format results.

How does auth work for the browser versus the tool?

The browser usually sends a session cookie or bearer token. The MCP server authenticates at its own transport, often OAuth or an API key. Each adapter resolves that to the same ctx.userId before calling the service function, so permissions are enforced in one place.

What should the tool return when validation fails?

Return isError: true and a message naming each field and the rule it broke. That gives the assistant enough to fix its next call without a human stepping in.

Next step

Pick one write endpoint you aleeady have, move its validation into a zod schema, and register it as a single MCP tool on a local server. Point Claude or Cursor at it, submit one valid and one invalid call, and compare the results with what your React form shows for the same inputs.

This system

Custom MCP Server

$3,000 setup
+ $349/month care plan

Third-party platform and model usage billed to you directly. No lock-in. Live in 10 days.

More on Custom MCP Server