Files
zero-fucks-given/docs/architecture/react-frontend/layers/view.md
T

4.6 KiB

Views Layer

Components

Reusable UI blocks: render UI, accept props, handle simple UI state (hover, toggle, open/close), compose other components. UserAvatar, ProductBadge.

No API calls, mutations, complex state management, or business workflows.

Widgets

Self-contained UI blocks that implement a complete piece of functionality — panels, sections, complex interactive blocks used to compose pages. A widget combines UI, logic, and interactions: it composes multiple components, may include forms, dialogs, or actions, may fetch data via data hooks or take it from props, and exposes a simple API to pages while hiding its internals.

A widget can use another widget when the nested widget is a clear functional sub-block, the parent is grouping or orchestrating UI, and the dependency stays loose:

export const ProjectOverviewWidget = ({ projectId }: Props) => {
 return (
   <div>
     <ProjectStatsWidget projectId={projectId} />
     <ProjectActivityWidget projectId={projectId} />
     <ProjectMembersWidget projectId={projectId} />
   </div>
 );
};

Forms

Form Architecture

view/forms/SomeForm/
| --- form.ts           # validation schema, types, default values
| --- Component.tsx     # UI + react-hook-form integration
| --- submit.ts         # submission logic
| --- constants.ts      # Optional: form IDs, UI constants
| --- schema.ts         # Optional validation schema. Good for large or multistep forms
| --- index.ts          # public exports

Form Logic

form.ts contains pure form logic: validation schema, form types, default values, helper templates.

export const validationSchema = z.object({
  first_name: z.string().min(3),
  last_name: z.string().min(3),
});

With i18n, create a function that returns the schema:

function createValidationSchema(
  translationFn: TFunction<"translation", undefined>, // Depend on library you use
) {
  return z.object({
    first_name: z.string({ message: translationFn("required") }),
    last_name: z.string({ message: translationFn("required") }),
  });
}

[! WARNING] Dont include i18n singleton last_name: z.string().min(1, i18n.t('required')) It will not translate errors when language state change

Default values come from factory functions, not inline objects:

export function createFormValues(): FormValues {
  return { first_name: "", last_name: "" };
}

export function toFormValues(data: SomeData): FormValues {
  return {
    first_name: data?.first_name ?? "",
    last_name: data?.last_name ?? "",
  };
}

Types for values and submit functions:

export type FormValues = z.infer<typeof validationSchema>;
export type SubmitFunc = (
  values: FormValues,
  handlers: UseFormReturn<FormValues>,
) => void;

Submit Functions

A useSubmit hook centralizes submission: it picks create vs update by values.id, calls the data/ layer mutations, invalidates list/detail queries on success, maps API validation errors onto form fields, and exposes submit + isSubmitting.

export const useSubmit = (params?: { onSuccess?: (id?: number) => void }) => {
  const { mutate: createMutation, isPending: isCreatePending } = useCreate();
  const { mutate: updateMutation, isPending: isUpdatePending } = useUpdate();

  const handleSubmit: SubmitFunc = (values, handlers) => {
    const mutation = values.id ? updateMutation : createMutation;
    mutation(values, {
      onSuccess() {
        invalidateList();
        if (values.id) invalidateDetailed(values.id);
        params?.onSuccess?.(values.id ?? undefined);
      },
      onError(error) {
        extractValidationErrors(error).forEach((e) => {
          handlers.setError(e.field as keyof typeof values, {
            type: "apiError",
            message: e.message,
          });
        });
      },
    });
  };

  return {
    submit: handleSubmit,
    isSubmitting: isCreatePending || isUpdatePending,
  };
};

Composed Form Elements

To avoid form boilerplate, wrap React Hook Form + shared UI into composed inputs. Each combines label, input, validation message, and layout, works with FormProvider via useFormContext, and can encapsulate any input type: text, switch, select, date, textarea, etc.

<FormProvider {...form}>
  <Component.FormElement
    id={MAIN_FORM_ID}
    onSubmit={form.handleSubmit((values) => onSubmit(values, form))}
  >
    <Component.FormGrid columns={2}>
      <Component.GridElement colSpan={1}>
        <Component.InputSwitch
          name="notificationsEnabled"
          required
          label="Enable notifications"
        />
      </Component.GridElement>
    </Component.FormGrid>
  </Component.FormElement>
</FormProvider>