dimah-formv0.2.0

React Client

useFormResponse — visibility, drafts, validation timing, and submit.

@dimah-form/react is a headless fill-session runtime. It tracks visible fields, local answers, draft autosave, and submit state. It does not ship inputs or CSS.


Setting Up the Client

Create a shared client instance configured with your server's base path and type definition:

lib/form-client.ts
"use client";

import { createFormClient } from "@dimah-form/react";
import type { Form } from "@/lib/form";

export const formClient = createFormClient<Form>({
  basePath: "/api/form",
});

export const { useFormClient, useFormResponse } = formClient;

The useFormResponse Hook

The useFormResponse hook connects your UI to a questionnaire fill session:

components/questionnaire.tsx
"use client";

import { emptyToNull, fieldLabel, fieldOptions } from "@dimah-form/react";
import type { FormSnapshot, ResponseRecord } from "@dimah-form/react";
import { useFormResponse } from "@/lib/form-client";
import type { Form } from "@/lib/form";

interface QuestionnaireProps {
  form: FormSnapshot;
  response?: ResponseRecord;
}

export function Questionnaire({ form, response }: QuestionnaireProps) {
  const q = useFormResponse<Form["$Infer"]["answers"]["onboarding"]>({
    snapshot: form,
    response, // Pass existing row when editing an existing draft
    autosave: { debounceMs: 1000 },
    validate: "submit", // "submit" (default) or "change"
    onSubmitted: (row) => {
      console.log("Successfully submitted:", row.id);
    },
  });

  if (q.inactive) {
    return <p>This form is currently unavailable.</p>;
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        void q.submit();
      }}
      className="mx-auto max-w-md space-y-4"
    >
      <h2 className="text-xl font-bold">{form.title}</h2>

      {/* Render visible fields based on showWhen logic */}
      {q.visibleFields.map((field) => {
        const binding = q.field(field.id);

        return (
          <div key={field.id} className="space-y-1">
            <label className="block text-sm font-medium">
              {fieldLabel(field)}
              {binding.required && <span className="ml-1 text-red-500">*</span>}
            </label>
            <input
              type="text"
              value={typeof binding.value === "string" ? binding.value : ""}
              disabled={binding.disabled}
              onChange={(e) => binding.onChange(emptyToNull(e.target.value))}
              className="w-full rounded border p-2"
            />
            {binding.error && (
              <p className="text-xs text-red-500">{binding.error}</p>
            )}
          </div>
        );
      })}

      {q.error && <p className="text-sm text-red-500">{q.error}</p>}

      <div className="flex gap-2">
        <button
          type="button"
          disabled={q.pending !== undefined || q.locked}
          onClick={() => void q.saveDraft()}
          className="rounded border px-4 py-2"
        >
          {q.pending === "save" ? "Saving..." : "Save Draft"}
        </button>

        <button
          type="submit"
          disabled={q.pending !== undefined || q.locked}
          className="rounded bg-black px-4 py-2 text-white"
        >
          {q.pending === "submit" ? "Submitting..." : "Submit"}
        </button>
      </div>
    </form>
  );
}

Session State Reference

useFormResponse returns a comprehensive state object:

PropertyTypeDescription
q.visibleFieldsFormField[]Fields that satisfy showWhen conditions based on current answers.
q.answersTAnswersCurrent in-memory dictionary of answers.
q.status"draft" | "submitted" | "abandoned"Lifecycle status of the current response.
q.dirtybooleantrue if local answers have unpersisted modifications.
q.pending"save" | "submit" | "reopen" | "abandon" | "refresh" | undefinedActive asynchronous mutation in flight.
q.lockedbooleantrue if status !== "draft" (response is frozen from edits).
q.errorstring | undefinedTop-level error message from the last failed operation.
q.issuesRecord<string, string>Map of field ID to human-readable validation error message.
q.issueCodesRecord<string, string>Map of field ID to machine-readable issue code (e.g., "REQUIRED", "TOO_SHORT").
q.completion{ answered, total, percent }Progress metrics for visible fields.
q.inactivebooleantrue if the form status is not "active" and no existing response was loaded.

Actions & Methods

q.field(id)

Returns a typed FormFieldBinding helper object for connecting an input control:

const {
  field, // The FormField definition object
  value, // Current answer value (or undefined)
  error, // Error message string for this field
  errorCode, // Issue code (e.g. "REQUIRED")
  disabled, // boolean (true when locked or pending)
  required, // boolean
  onChange, // (value: unknown) => void
  onBlur, // () => void
} = q.field("fullName");

q.setAnswer(id, value)

Directly sets an answer value in the local state. Passing null removes the answer key.

q.saveDraft(options?)

Explicitly saves the current answers to the backend as a draft with optimistic concurrency (updatedAt) checks.

q.submit(options?)

Validates all visible required fields against the snapshot schema. If valid, persists answers and locks the response to "submitted".

q.reopen()

Unlocks a previously "submitted" or "abandoned" response back to "draft".

q.abandon()

Marks the current draft as "abandoned", preventing further edits.

q.refresh()

Fetches the latest response record from the server and syncs local state.

q.reset()

Resets in-memory answers back to the last persisted server state.


Advanced Features

1. Automatic Draft Autosave

Enable debounced background autosaving so users never lose progress:

const q = useFormResponse({
  snapshot: form,
  autosave: { debounceMs: 1500 }, // Debounce timer in milliseconds
});

2. Validation Timing (validate)

  • "submit" (default): Validates required fields only when the user attempts to submit. Once submitted, subsequent edits immediately clear resolved errors.
  • "change": Re-validates fields in real-time as the user types.
const q = useFormResponse({
  snapshot: form,
  validate: "change",
});

3. Resuming Existing User Drafts

If your users are authenticated, pass resume: true with a respondentId to automatically reconnect to their existing draft:

const q = useFormResponse({
  snapshot: form,
  respondentId: session.user.id,
  resume: true,
});

Next Steps

On this page