LocalSpace
PackagesUI Library

Creating a Form

A step-by-step guide to building a complete form using the tools in this boilerplate.

This guide walks through the implementation of the Sign In page (@frontend/app/src/pages/signin.tsx) to demonstrate how the various components and hooks from @localspace/ui work together with the tuyau API client.

The Goal

We want to create a secure sign-in form with the following features:

  • Email and password fields.
  • Client-side and server-side validation.
  • A CAPTCHA to prevent bots.
  • Informative notifications for success and error states.
  • Specific UI changes for custom errors (e.g., showing a "Resend Verification" button).

1. The API Client (tuyau.ts)

Before building the UI, it's important to understand how the frontend communicates with the backend. The API client is configured in @frontend/app/src/lib/tuyau.ts.

// @frontend/app/src/lib/tuyau.ts
import { api } from "@localspace/backend-core/api";
import { createTuyau } from "@tuyau/client";
import { createTuyauContext } from "@tuyau/react-query";
import { cookieManager } from "./cookie_manager";
import { env } from "@/config/env";

export const client = createTuyau({
  api, // Type definitions imported from the backend
  baseUrl: env.NEXT_PUBLIC_BACKEND_URL,
  hooks: {
    // This hook runs before every single API request
    beforeRequest: [
      (request) => {
        // Automatically attach the auth token if it exists
        const token = cookieManager.getCookie("token");
        if (token) {
          request.headers.set("Authorization", `Bearer ${token}`);
        }

        // Automatically attach the captcha token if it exists
        const captcha = cookieManager.getCookie("captcha");
        if (captcha) {
          request.headers.set("captcha", captcha);
        }
      },
    ],
  },
});

export const { TuyauProvider, useTuyau, useTuyauClient } =
  createTuyauContext<typeof api>();

The key feature here is the beforeRequest hook. It automatically reads the token and captcha values from cookies and adds them as headers to every API call. This simplifies our component logic, as we don't need to manually add these headers for each mutation.

2. The Form Page (signin.tsx)

This file brings everything together. Let's break it down.

Setup and State

First, we import all the necessary hooks and components and define the local state for the page.

// @frontend/app/src/pages/signin.tsx

// ... imports
import { useSession } from "@/lib/hooks/use_session";
import { useTuyau } from "@/lib/tuyau";
import { useFormMutation } from "@/lib/hooks/use_form_mutation";
import { useCaptcha } from "@localspace/ui/components";
import { handleError } from "@localspace/ui/lib/handle_error";

export default function Page() {
  const session = useSession();
  const tuyau = useTuyau();
  const router = useRouter();
  const { captchaRef, resetCaptcha } = useCaptcha();

  // Local state to manage the UI
  const [isCaptchaReady, setIsCaptchaReady] = useState(false);
  const [showResend, setShowResend] = useState(false);

  // ... rest of the component
}

The Mutation

We use our useFormMutation hook to define the form's initial values, validation, and the API mutation logic.

// ... inside the Page component

const { form, mutation } = useFormMutation({
  // 1. Mutation logic from TanStack Query
  mutation: tuyau.api.v1.customer.auth.signin.$post.mutationOptions({
    onSuccess: (res) => {
      // On success, store the token and redirect
      cookieManager.setCookie("token", res.token.value, {
        expires: new Date(res.token.expiresAt),
      });
      notifications.show({ message: res.message });
      router.push("/app");
    },
    onError: (error) => {
      resetCaptcha();
      setShowResend(false);
      // Use our global error handler
      handleError(error, {
        form,
        // Handle a specific error code from the backend
        onErrorData: (errorData) => {
          if (errorData.code === "EMAIL_NOT_VERIFIED") {
            setShowResend(true);
          }
        },
      });
    },
  }),
  // 2. Initial values for the Mantine form
  initialValues: {
    payload: {
      email: "",
      password: "",
    },
  },
});

This setup is declarative and co-locates all the logic related to the sign-in action.

Rendering the UI

Finally, we render the components. We use the Form component from @localspace/ui to wrap our inputs. It provides the loading and isDirty states to its children, which we use to control the UI.

// ... inside the Page component's return statement

<Form mutation={mutation} submit={(d) => mutation.mutate(d)} form={form}>
  {({ loading, isDirty }) => (
    <>
      <TextInput
        label="Email"
        {...form.getInputProps("payload.email")}
        disabled={loading}
      />
      <PasswordInput
        label="Password"
        {...form.getInputProps("payload.password")}
        disabled={loading}
      />

      {/* Conditionally render the Captcha only when the user starts typing */}
      {isDirty && (
        <Captcha
          ref={captchaRef}
          siteKey={env.NEXT_PUBLIC_CAPTCHA_PUBLIC_KEY}
          onMessage={(message) => notifications.show({ message })}
          setToken={(t) => {
            if (t) {
              cookieManager.setCookie("captcha", t);
              setIsCaptchaReady(true);
            } else {
              cookieManager.removeCookie("captcha");
              setIsCaptchaReady(false);
            }
          }}
        />
      )}

      <Button
        type="submit"
        loading={loading}
        disabled={!isDirty || !isCaptchaReady}
      >
        Sign In
      </Button>
    </>
  )}
</Form>

This structure effectively combines the UI components with the form state and mutation logic, resulting in a robust and maintainable form.