IlmHamroh
JavaScript Full-stack/19-qism. React: routing, server state, formalar33/36-dars25 daqiqa
Mundarija (31)

React Hook Form useFieldArray, ko'p qadamli forma va async validatsiya

Qisqacha: Ro'yxat ko'rinishidagi maydonlar — useFieldArray: fields (har birida barqaror id — key uchun), append, remove, move. Ko'p qadamli forma (wizard) — bitta useForm, har "Keyingi" da faqat shu qadam maydonlari trigger([...]) bilan tekshiriladi. Serverda tekshiriladigan qoida — async validate yoki async superRefine; har harfda so'rov ketmasligi uchun mode/reValidateMode yoki debounce. Saqlanmagan o'zgarish — useBlocker (ilova ichida) va beforeunload (tab yopilganda); qoralama — avtosaqlash.

Bu darsda

  • useFieldArray bilan qo'shiladigan va o'chiriladigan maydonlar yozasiz va key={field.id} nega shartligini tushunasiz.
  • Ko'p qadamli formani bitta useForm va trigger bilan quraysiz, qadam almashganda fokusni boshqarasiz.
  • Async tekshiruvni yozasiz va so'rovlar sonini mode va debounce bilan kamaytirasiz.
  • Saqlanmagan o'zgarishni useBlocker va beforeunload bilan himoya qilasiz, qoralamani avtomatik saqlaysiz.
  • vazifalar ga «Qadamlarga bo'lib qo'shish» formasini qo'shasiz.

Oldin bilishingiz kerak: RHF II: Controller, watch va tahrirlash formasi, Zod chuqurroq, Debounce va throttle, React Router IV: data mode.

1. Nega bu kerak?

Sardor uyni ta'mirlashni rejalashtiryapti: "Bo'yoq olish #ta'mir", "Devorni tozalash #ta'mir", "Bo'yash #ta'mir"... vazifalar ga har birini alohida yozish va har safar tegni takrorlash zerikarli. U bitta formani xohlaydi: avval teg, keyin qadamlar ro'yxati (nechta bo'lsa shuncha), oxirida ko'rib chiqish — va hammasi bir bosishda qo'shilsin.

Bu bitta formada uchta yangi muammo bor:

  1. Maydonlar soni oldindan noma'lum. Uch qadam ham, o'n qadam ham bo'lishi mumkin. Har biriga register("qadam1"), register("qadam2") deb yozib bo'lmaydi.
  2. Forma bir necha bosqichda. Mehmon tegni yozadi, "Keyingi" ni bosadi — faqat teg tekshirilishi kerak, hali yozilmagan qadamlar emas.
  3. Ba'zi qoidalarni faqat server biladi. "Bo'yoq olish #ta'mir" ro'yxatda allaqachon bormi? Boshqa qurilmada kimdir hozirgina qo'shgan bo'lishi mumkin — so'rash kerak.

Hayotiy o'xshatish: bank kartasini rasmiylashtirish. Xodim hujjatlarni bosqichma-bosqich so'raydi: avval pasport, keyin manzil, keyin imzo. Har bosqichda faqat shu bosqich tekshiriladi. Oila a'zolari ro'yxati — nechta bo'lsa shuncha qator. Oxirida esa "bu raqam bazada bormi?" degan savol markaziy idoraga yuboriladi.

2. useFieldArray — dinamik maydonlar

2.1 Qo'shish va o'chirish

Buyurtmada taomlar ro'yxati: har qatorda taom nomi va soni. useFieldArray({ control, name }) massiv ko'rinishidagi maydon bilan ishlaydi:

jsx
import { useFieldArray, useForm } from "react-hook-form";

export default function App() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { items: [{ dish: "Osh", qty: 2 }] },
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: "items",
  });
  return (
    <form onSubmit={handleSubmit((d) => console.log(d.items))}>
      <ol>
        {fields.map((field, i) => (
          <li key={field.id}>
            <input
              id={`dish-${i}`}
              aria-label={`${i + 1}-taom`}
              {...register(`items.${i}.dish`)}
            />
            <input
              id={`qty-${i}`}
              type="number"
              aria-label={`${i + 1}-taom soni`}
              {...register(`items.${i}.qty`, { valueAsNumber: true })}
            />
            <button
              type="button"
              aria-label={`O'chirish: ${i + 1}-taom`}
              onClick={() => remove(i)}
            >
              ✕
            </button>
          </li>
        ))}
      </ol>
      <button
        type="button"
        onClick={() => append({ dish: "", qty: 1 })}
      >
        Taom qo'shish
      </button>
      <button>Buyurtma</button>
    </form>
  );
}

Konsolda:

text
(2) [{dish: 'Manti', qty: 3}, {dish: '', qty: 1}]

Ketma-ketlik: boshida bitta qator (osh). "Taom qo'shish" — ikkinchi qator, unga "Manti, 3". Yana bitta qo'shildi — uchinchi, bo'sh. Keyin birinchi qator (osh) o'chirildi. Natijada ikki qator: manti va bo'sh qator.

Asosiy vositalar:

Metod Nima qiladi
fields ko'rsatish uchun massiv; har elementda id
append(obj) / prepend(obj) oxiriga / boshiga qo'shadi
insert(i, obj) i o'rniga qo'yadi
remove(i) i ni o'chiradi (remove() — hammasini)
move(a, b) / swap(a, b) joyini almashtiradi
update(i, obj) / replace(arr) qiymatni almashtiradi

Maydon nomi — nuqtali yo'l: items.0.dish, items.1.qty. Sxemadagi path ham shunday (Zod chuqurroq dagi ["dishes", 2]).

append dan keyin RHF fokusni yangi qatorning birinchi maydoniga o'tkazadi (shouldFocus: true — sukut). Bobur yangi qatorni Tab bilan izlamaydi. Kerak bo'lmasa — append(obj, { shouldFocus: false }).

2.2 Nega key={field.id}, indeks emas

fields[i].id — RHF bergan barqaror kalit. Indeks (key={i}) bilan nima bo'lishini key darsidan bilasiz: o'rtadagi qator o'chirilsa, keyingilari bitta yuqoriga suriladi. React esa "2-qator o'chdi" emas, "oxirgi qator o'chdi, qolganlariga yangi props keldi" deb o'ylaydi. Har qatorning ichki holati (masalan, ochilgan "izoh" maydoni yoki maxsus komponentning state'i) noto'g'ri qatorga ko'chadi.

field.id qiymat bilan birga ko'chadi: o'chirilgan qatorning komponenti o'chadi, qolganlari o'z holatini saqlaydi.

Diqqat: fields — ko'rsatish uchun, qiymatlar uchun emas. Unda boshlang'ich qiymatlar turadi: mehmon maydonga yozganda fields[i].dish yangilanmaydi. Joriy qiymat kerak bo'lsa — useWatch({ control, name: "items" }) yoki getValues("items") (RHF II).

2.3 Massivga qoidalar

Massivning o'ziga ham qoida bo'ladi: "kamida bitta taom", "ko'pi bilan 10 ta". Sxemada z.array(...).min(1).max(10). Bunday xato massivga tegishli, biror qatorga emas. RHF uni errors.items.root (yoki eski holatda errors.items.message) ga qo'yadi. Har qatorning o'z xatosi esa — errors.items[i].dish. Zod chuqurroq dagi 3-mashq (takror taom, jami porsiya) aynan shu formaning sxemasi edi.

Tekshirib ko'ring: Uch qatorli ro'yxatda o'rtadagisi o'chirildi. register nomlari (items.0, items.1, items.2) bilan nima bo'ladi?

Javob

Nomlar indeksga bog'liq, shuning uchun qayta hisoblanadi: uchinchi qator endi items.1 bo'ladi. Buni RHF o'zi qiladi — qiymatlar massivdan o'chadi va qolganlari suriladi. Sizdan talab bitta: nomni har renderda items.${i} deb indeks bilan yozing, key ga esa field.id bering. Nom — "qaysi o'rinda", kalit — "qaysi qator".

3. Ko'p qadamli forma (wizard)

3.1 Bitta forma, bir necha qadam

Ko'p qadamli formani ikki usulda qurish mumkin:

  • Har qadam — alohida useForm. Har biri o'z sxemasi bilan, natijalar umumiy state'da yig'iladi. Qadamlar mustaqil bo'lsa qulay, lekin qadamlar orasidagi qoidalar (teg + qadam uzunligi) qiyinlashadi.
  • Bitta useForm, qadamlar — ko'rinish. Hamma maydonlar bitta formada, faqat hozirgi qadamniki ko'rinadi. "Keyingi" bosilganda trigger([...nomlar]) — faqat shu maydonlarni tekshiradi va true/false qaytaradi.

vazifalar ikkinchisini tanlagan. Qadam o'zgarganda oldingi maydonlar ekrandan yo'qoladi (unmount), lekin qiymatlari formada qoladi — shouldUnregister: false (sukut). Uch qadamli bron:

jsx
import { useState } from "react";
import { useForm } from "react-hook-form";

const STEPS = ["Ism", "Mehmonlar", "Tasdiq"];
const FIELDS = [["name"], ["guests"], []];

export default function App() {
  const [step, setStep] = useState(0);
  const {
    register,
    trigger,
    getValues,
    formState: { errors },
  } = useForm({ defaultValues: { name: "", guests: "" } });

  async function next() {
    if (await trigger(FIELDS[step], { shouldFocus: true })) {
      setStep(step + 1);
    }
  }

  return (
    <form noValidate onSubmit={(e) => e.preventDefault()}>
      <p>
        {step + 1}/{STEPS.length}: {STEPS[step]}
      </p>
      {step === 0 && (
        <p>
          <label htmlFor="name">Ism</label>
          <input
            id="name"
            {...register("name", { required: "Ismingizni yozing" })}
          />
          <span role="alert"> {errors.name?.message}</span>
        </p>
      )}
      {step === 1 && (
        <p>
          <label htmlFor="guests">Mehmonlar soni</label>
          <input
            id="guests"
            type="number"
            {...register("guests", { required: "Nechta mehmon?" })}
          />
        </p>
      )}
      {step === 2 && (
        <p>
          {getValues("name")}, {getValues("guests")} kishi —
          tasdiqlaysizmi?
        </p>
      )}
      {step < 2 && (
        <button type="button" onClick={() => void next()}>
          Keyingi
        </button>
      )}
    </form>
  );
}

Ekranda:

text
3/3: Tasdiq
Malika, 4 kishi — tasdiqlaysizmi?

Birinchi "Keyingi" bo'sh ism bilan — xato chiqdi, qadam o'zgarmadi (trigger false qaytardi). Ism yozildi — ikkinchi qadamga o'tdi. Uchinchi qadamda ism va son hali bor, garchi ularning maydonlari allaqachon ekrandan ketgan bo'lsa ham.

FIELDS — har qadamda nima tekshirilishi. Uchinchi qadamda bo'sh massiv: tekshiradigan maydon yo'q. Agar trigger() argumentsiz chaqirilsa — hamma maydon tekshiriladi, shu jumladan hali ko'rinmaganlar ham. Wizard'da bu xato.

stateDiagram-v2
  [*] --> Teg
  Teg --> Qadamlar: Keyingi (trigger teg)
  Qadamlar --> Teg: Orqaga
  Qadamlar --> Tekshirish: Keyingi (trigger qadamlar)
  Tekshirish --> Qadamlar: Orqaga
  Tekshirish --> [*]: Yaratish (butun sxema)

3.2 Fokus va a11y

Qadam almashganda eski maydonlar yo'qoladi, fokus esa... hech qayerda. Ekran o'quvchi Malika qayerda ekanini bilmaydi. Yechim — qadam sarlavhasiga fokus berish. vazifalar da bu <legend tabIndex={-1}> va useLayoutEffect (pastda, «Vazifalar qadami»da). Qoida Fokus boshqaruvi darsidan: ekranning katta qismi almashganda — fokusni yangi qismning sarlavhasiga.

Yana ikki odat:

  • Qadamlar ro'yxati (<ol>) va joriy qadamda aria-current="step" — "3 qadamdan 2-chisi" ko'rinadi va eshitiladi.
  • «Orqaga» tugmasi qiymatlarni yo'qotmaydi — forma bitta. Lekin orqaga qaytganda tekshirish kerak emas: mehmon tuzatish uchun qaytgan.

Tekshirib ko'ring: Wizard'da useForm({ shouldUnregister: true }) qo'yilsa, uchinchi qadamda getValues("name") nima beradi?

Javob

undefined. shouldUnregister: true — maydon ekrandan ketganda (unmount) uning qiymati ham formadan o'chiriladi. Wizard'da qadam maydonlari aynan shunday ketadi, demak oxirgi qadamga yetganda faqat oxirgi qadam qiymatlari qoladi. Sukut — false, wizard uchun aynan shu kerak.

4. Async validatsiya

4.1 Serverdan so'raladigan qoida

"Bu nom serverda bormi?" — sxema bilmaydi, server biladi. RHF da ikki joy bor: register dagi async validate yoki sxemadagi async refine / superRefine (unda resolver o'zi safeParseAsync ni ishlatadi). Avval validate ni sinaymiz — mode: "onChange" bilan, soxta server 200 ms da javob beradi va har so'rovni sanaydi:

jsx
import { useForm } from "react-hook-form";

const TAKEN = ["ali", "vali"];
let requests = 0;

function isTaken(login) {
  requests += 1;
  console.log(`so'rov ${requests}: ${login}`);
  return new Promise((resolve) =>
    setTimeout(() => resolve(TAKEN.includes(login)), 200),
  );
}

export default function App() {
  const {
    register,
    formState: { errors, isValidating },
  } = useForm({ mode: "onChange", defaultValues: { login: "" } });
  return (
    <form>
      <p>
        <label htmlFor="login">Login</label>
        <input
          id="login"
          {...register("login", {
            validate: async (v) =>
              !(await isTaken(v)) || "Bu login band",
          })}
        />
      </p>
      <p role="status">
        {isValidating ? "Tekshirilmoqda…" : errors.login?.message}
      </p>
    </form>
  );
}

Konsolda:

text
so'rov 1: a
so'rov 2: al
so'rov 3: ali

Ekranda:

text
Login
Bu login band

Uch harf — uch so'rov. Har harfda serverga. 1 000 foydalanuvchi bir vaqtda ro'yxatdan o'tsa — har biri o'nlab so'rov. Bundan tashqari, javoblar tartibsiz kelishi mumkin: "al" ning javobi "ali" nikidan keyin kelsa, eskisi yangisining ustiga yozadi. isValidating — tekshiruv davom etayotgan payt (isSubmitting ning tekshiruvdagi juftligi).

4.2 So'rovni kamaytirish

Uch yo'l bor, eng soddasidan:

  1. mode ni o'zgartirish. mode: "onBlur" yoki "onSubmit" va reValidateMode: "onBlur" — tekshiruv maydondan chiqqanda yoki yuborishda. Bitta maydon — bitta so'rov. vazifalar aynan shu yo'lni tanlagan.
  2. Debounce. Mehmon yozishni to'xtatgach (masalan, 400 ms) bir marta so'rash (Debounce). "Login bo'sh" kabi jonli maslahat kerak bo'lsa.
  3. Kesh. Bir marta tekshirilgan qiymatni qayta so'ramaslik — Map yoki TanStack Query (kesh).

mode: "onBlur" bilan o'sha forma (tugma fokusni keyingi maydonga o'tkazadi — Tab o'rnida, RHF I dagidek):

jsx
import { useForm } from "react-hook-form";

const TAKEN = ["ali", "vali"];
let requests = 0;

function isTaken(login) {
  requests += 1;
  console.log(`so'rov ${requests}: ${login}`);
  return new Promise((resolve) =>
    setTimeout(() => resolve(TAKEN.includes(login)), 200),
  );
}

export default function App() {
  const {
    register,
    formState: { errors },
  } = useForm({ mode: "onBlur", defaultValues: { login: "" } });
  return (
    <form>
      <p>
        <label htmlFor="login">Login</label>
        <input
          id="login"
          autoFocus
          {...register("login", {
            validate: async (v) =>
              !(await isTaken(v)) || "Bu login band",
          })}
        />
      </p>
      <p>
        <label htmlFor="name">Ism</label>
        <input id="name" />
      </p>
      <p role="status">{errors.login?.message}</p>
      <button
        type="button"
        onClick={() => document.getElementById("name").focus()}
      >
        Keyingi maydon
      </button>
    </form>
  );
}

Konsolda:

text
so'rov 1: ali

Ekranda:

text
Login
Ism
Bu login band
Keyingi maydon

Uch harf — bitta so'rov, yozib bo'lingan login bilan.

Diqqat: Brauzerdagi "band emas" — kafolat emas. Siz "Keyingi" ni bosgan soniyada boshqa odam o'sha loginni olishi mumkin. Haqiqiy tekshiruv — yuborishda, serverda (ma'lumotlar bazasidagi noyoblik sharti). Server rad etsa — RHF + Zod dagi setError.

5. Saqlanmagan o'zgarishlar

5.1 Ilova ichida: useBlocker

Mehmon uzun formani yarim to'ldirdi va menyudagi havolani bosdi — hammasi yo'qoldi. React Router'da useBlocker(shart) navigatsiyani to'xtatadi. U blocker qaytaradi: state — "blocked" bo'lsa, foydalanuvchidan so'raymiz, keyin blocker.proceed() (ketish) yoki blocker.reset() (qolish). Shart — RHF ning isDirty i:

jsx
import { useForm } from "react-hook-form";
import {
  createMemoryRouter,
  Link,
  RouterProvider,
  useBlocker,
} from "react-router";

function EditDish() {
  const {
    register,
    formState: { isDirty },
  } = useForm({ defaultValues: { price: "35000" } });
  const blocker = useBlocker(isDirty);
  return (
    <form>
      <p>
        <label htmlFor="price">Narxi</label>
        <input id="price" {...register("price")} />
      </p>
      <Link to="/">Menyu</Link>
      {blocker.state === "blocked" && (
        <div role="alertdialog" aria-label="Chiqishni tasdiqlang">
          <p>Saqlanmagan o'zgarish bor. Chiqasizmi?</p>
          <button type="button" onClick={() => blocker.proceed()}>
            Chiqish
          </button>
          <button type="button" onClick={() => blocker.reset()}>
            Qolish
          </button>
        </div>
      )}
    </form>
  );
}

const router = createMemoryRouter(
  [
    { path: "/", element: <Link to="/osh">Tahrirlash</Link> },
    { path: "/osh", element: <EditDish /> },
  ],
  { initialEntries: ["/"] },
);

export default function App() {
  return <RouterProvider router={router} />;
}

Ekranda:

text
Narxi
Menyu

Narx o'zgardi → "Menyu" bosildi → router to'xtadi va so'radi (tekshiruvchi oynani ko'rdi) → "Qolish" → forma joyida, kiritilgan narx bilan. "Chiqish" bosilganda esa navigatsiya davom etadi.

Natija oynasida manzil satri yo'q, shuning uchun xotiradagi router — loyihada createBrowserRouter, useBlocker bir xil ishlaydi. useBlocker faqat data router (data mode) bilan ishlaydi — <BrowserRouter> bilan emas.

window.confirm dan ko'ra o'z oynangiz yaxshiroq: matnni siz boshqarasiz va u a11y talablariga mos. Haqiqiy loyihada bu <dialog> bo'ladi (dialog).

5.2 Tab yopilganda: beforeunload

useBlocker faqat ilova ichidagi navigatsiyani ko'radi. Tab yopilsa, sahifa yangilansa yoki boshqa saytga o'tilsa — bu brauzerning ishi. Buning uchun beforeunload hodisasi:

jsx
useEffect(() => {
  if (!isDirty) return;
  function warn(event) {
    event.preventDefault(); // brauzer o'z oynasini ko'rsatadi
  }
  window.addEventListener("beforeunload", warn);
  return () => window.removeEventListener("beforeunload", warn);
}, [isDirty]);

React Router'da bunga tayyor hook bor: useBeforeUnload(fn). Uch narsani bilish kerak:

  • Brauzer o'z matnini ko'rsatadi ("Saytdan chiqasizmi? O'zgarishlar saqlanmasligi mumkin"). O'z matningizni berib bo'lmaydi — bu firibgarlikka qarshi cheklov.
  • Hodisa faqat o'zgarish bor paytda ulansin (if (!isDirty) return). Doim ulangan beforeunload brauzerning orqaga-oldinga keshini (bfcache) buzadi — sahifa qaytganda tez ochilmaydi.
  • Bu blok saytda «Ishga tushir» siz: natija oynasini "yopib" bo'lmaydi. O'z loyihangizda sinang.

5.3 Avtosaqlash: qoralama

Ogohlantirishdan ham yaxshisi — hech narsa yo'qolmasin. Avtosaqlash: mehmon yozishni to'xtatgach, qoralama localStorage ga yoziladi, keyingi ochilishda defaultValues ga qaytadi. RHF ning watch(callback) i qayta chizmasdan har o'zgarishda funksiya chaqiradi (RHF II), debounce — setTimeout:

jsx
import { useEffect } from "react";
import { useForm } from "react-hook-form";

const KEY = "bron:qoralama";

function loadDraft() {
  try {
    return JSON.parse(localStorage.getItem(KEY)) ?? { note: "" };
  } catch {
    return { note: "" };
  }
}

export default function App() {
  const { register, watch } = useForm({ defaultValues: loadDraft() });

  useEffect(() => {
    let timer;
    const subscription = watch((values) => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        localStorage.setItem(KEY, JSON.stringify(values));
        console.log("qoralama saqlandi:", values.note);
      }, 400);
    });
    return () => {
      clearTimeout(timer);
      subscription.unsubscribe();
    };
  }, [watch]);

  return (
    <form>
      <label htmlFor="note">Izoh</label>
      <textarea id="note" {...register("note")} />
    </form>
  );
}

Konsolda:

text
qoralama saqlandi: Deraza yonida

Ikki o'zgarish — bitta yozuv: ikkinchi o'zgarish birinchisining taymerini bekor qildi. Obuna (watch(callback)) effektda ulanadi. Effekt tozalanganda u unsubscribe qilinadi (useEffect I). Natija oynasida localStorage ham ishlaydi: u soxta nusxa va sahifa yangilanganda tozalanadi. Loyihada esa haqiqiy.

Qoralama — shu qurilmada. Yuborilgandan keyin uni o'chiring (localStorage.removeItem(KEY)), aks holda keyingi ochilishda eski matn chiqadi. Parol, karta raqami kabi maydonlar qoralamaga yozilmaydi.

6. Vazifalar qadami: «Qadamlarga bo'lib qo'shish» (holat 33)

6.1 Nima quramiz

Yangi sahifa /yangi: uch qadam — "Teg", "Qadamlar", "Tekshirish". Natija — bir nechta vazifa, hammasi bitta teg bilan: "Bo'yoq olish #ta'mir", "Bo'yash #ta'mir". Ro'yxat sahifasidagi tezkor forma ostida havola: "Katta ishni qadamlarga bo'lib qo'shish…".

bash
git switch -c feat/qadamlarga-bolish

6.2 Sxema: sxemalar/loyiha-formasi.ts

Kanon (to'liq; izoh qatorlari telefonga sig'ishi uchun bo'lingan):

ts
// loyiha-formasi.ts — "Qadamlarga bo'lib qo'shish" ustasi (wizard,
// 19/#33) sxemasi. Bitta forma, uch qadam: har qadam o'z
// maydonlarini tekshiradi (trigger), oxirida — butun sxema. Natija —
// bir nechta vazifa: har qadam alohida vazifa, hammasi bitta teg
// bilan
import { z } from "zod/mini";
import { Vazifa } from "../vazifa.ts";

export const QADAM_ENG_KOP = 10;

// Teg (12/#08 qoidasi): # bilan, harf/raqam/_ va ichida apostrof
const TEG = /^#[\p{L}\p{N}_]+(?:['‘’][\p{L}\p{N}_]+)*$/u;

/** Vazifa matni: "<qadam> <teg>" */
export function vazifaMatni(qadam: string, teg: string): string {
  return `${qadam.trim()} ${teg.trim()}`;
}

/**
 * serverdaBormi — ASYNC tekshiruv (19/#33): serverdagi ro'yxatda shu
 * matnli vazifa bormi. Sxema serverni bilmaydi — funksiya tashqaridan
 */
export function loyihaSxemasi(
  serverdaBormi: (matnlar: readonly string[]) => Promise<string | null>,
) {
  const qadam = z.object({
    matn: z
      .string()
      .check(
        z.trim(),
        z.minLength(1, "Qadamni yozing yoki olib tashlang."),
      ),
  });
  return z
    .object({
      teg: z
        .string()
        .check(
          z.trim(),
          z.regex(TEG, "Teg # bilan boshlansin, bo'shliqsiz: #ta'mir."),
        ),
      qadamlar: z
        .array(qadam)
        .check(
          z.minLength(1, "Kamida bitta qadam."),
          z.maxLength(
            QADAM_ENG_KOP,
            `Ko'pi bilan ${QADAM_ENG_KOP} qadam.`,
          ),
        ),
    })
    .check(
      // Butun forma darajasidagi qoidalar: matn uzunligi va takror
      // (teg bilan birga hisoblanadi) — xato tegishli qadam maydoniga
      z.superRefine(async ({ teg, qadamlar }, ctx) => {
        const matnlar = qadamlar.map((q) => vazifaMatni(q.matn, teg));
        matnlar.forEach((matn, i) => {
          if (matn.length > Vazifa.MAKS_UZUNLIK) {
            ctx.issues.push({
              code: "custom",
              input: matn,
              path: ["qadamlar", i, "matn"],
              message: `Teg bilan ${Vazifa.MAKS_UZUNLIK} belgidan oshdi.`,
            });
          }
          if (matnlar.indexOf(matn) !== i) {
            ctx.issues.push({
              code: "custom",
              input: matn,
              path: ["qadamlar", i, "matn"],
              message: "Bu qadam yuqorida bor.",
            });
          }
        });
        if (ctx.issues.length > 0) {
          return;
        }
        const bor = await serverdaBormi(matnlar);
        if (bor !== null) {
          ctx.issues.push({
            code: "custom",
            input: bor,
            path: ["qadamlar", matnlar.indexOf(bor), "matn"],
            message: `«${bor}» ro'yxatda allaqachon bor.`,
          });
        }
      }),
    );
}

export type LoyihaKirishi = z.input<ReturnType<typeof loyihaSxemasi>>;
export type LoyihaQiymati = z.output<ReturnType<typeof loyihaSxemasi>>;

To'rt darsning g'oyalari bitta faylda:

  • Teg qoidasi — ro'yxatdagi teglar bilan bir xil muntazam ifoda (12-qism dagi Unicode u bayrog'i): #ta'mir ham, #oʻquv ham.
  • z.array(qadam).check(minLength, maxLength) — massivga qoida. Xato errors.qadamlar.root ga tushadi.
  • superRefine — butun forma darajasida. "Teg bilan 100 belgidan oshdi" faqat teg va qadam birga ma'lum bo'lganda hisoblanadi. Xato path: ["qadamlar", i, "matn"] — aynan o'sha qator ostida.
  • Async va arzon tekshiruv tartibi. Avval mahalliy qoidalar (uzunlik, takror); xato bo'lsa — return, serverga so'rov yo'q. Faqat hammasi to'g'ri bo'lsa — await serverdaBormi(...). Arzon tekshiruv qimmatidan oldin.
  • z.input va z.output — ikkala tur eksport qilinadi, forma ularni useForm<Kirish, unknown, Chiqish> ga beradi (RHF + Zod).

6.3 Forma: sahifalar/YangiLoyiha.tsx

Fayl 270 qator — asosiy qismlari (kanon). Boshidagi izoh:

tsx
// YangiLoyiha.tsx — "/yangi" (19/#33): ko'p qadamli forma (wizard).
// BITTA useForm, uch qadam: qadam o'zgarganda maydonlar unmount
// bo'ladi, lekin qiymatlar formada qoladi (shouldUnregister: false —
// sukut). Har "Keyingi" — faqat shu qadam maydonlari tekshiriladi
// (trigger). Dinamik maydonlar — useFieldArray; serverdagi takror —
// async tekshiruv

useForm va async tekshiruv:

tsx
const QADAMLAR = ["Teg", "Qadamlar", "Tekshirish"] as const;
// Har qadamda qaysi maydonlar tekshiriladi
const MAYDONLAR: readonly (readonly FieldPath<LoyihaKirishi>[])[] = [
  ["teg"],
  ["qadamlar"],
  [],
];
// ...
  } = useForm<LoyihaKirishi, unknown, LoyihaQiymati>({
    // Async tekshiruv (19/#33): serverdagi ENG SO'NGGI ro'yxat
    // (keshda eskirgan bo'lishi mumkin — staleTime 0 bilan qayta
    // so'raladi)
    resolver: zodResolver(
      loyihaSxemasi(async (matnlar) => {
        const royxat = VazifalarRoyxati.dan(
          await mijoz.fetchQuery({
            ...royxatSorovi(mijoz),
            staleTime: 0,
          }),
        );
        return matnlar.find((m) => royxat.matnBormi(m, null)) ?? null;
      }),
    ),
    defaultValues: { teg: "", qadamlar: [{ matn: "" }] },
    // Tekshiruv — "Keyingi" va yuborishda; xato bo'lgach — maydondan
    // chiqqanda (har harfda serverga so'rov ketmasin)
    mode: "onSubmit",
    reValidateMode: "onBlur",
  });
  • MAYDONLAR — «Bitta forma, bir necha qadam» bo'limidagi FIELDS ning o'zi. FieldPath<…> turi faqat haqiqiy maydon nomlariga ruxsat beradi — "tegg" deb yozilsa, tsc xato beradi.
  • fetchQuery({ staleTime: 0 }) — Query keshida ro'yxat bor, lekin u 30 soniyagacha "yangi" hisoblanadi (kesh hayot sikli). Tekshiruv uchun hozirgi holat kerak — staleTime: 0 serverga qayta so'raydi. Boshqa qurilmada hozirgina qo'shilgan vazifa ham ko'rinadi.
  • mode: "onSubmit" + reValidateMode: "onBlur" — «So'rovni kamaytirish» bo'limidagi birinchi yo'l.

Diqqat qiling: resolver trigger(["teg"]) da ham butun sxemani ishlatadi (RHF + Zod), shu jumladan superRefine ni. Nega birinchi qadamda serverga so'rov ketmaydi? ctx.issues da faqat shu funksiya qo'shgan xatolar emas, oldingi qoidalarning xatolari ham bor. Birinchi qadamda qadam matni hali bo'sh (minLength(1) buzilgan) — ctx.issues.length > 0, funksiya return qiladi. Biz buni Node'da sinadik: teg va qadam bo'sh — ctx.issues 2 ta, so'rov yo'q; faqat qadam bo'sh — 1 ta, so'rov yo'q; hammasi to'g'ri — 0, so'rov ketdi.

"Keyingi" va fokus:

tsx
  // Qadam almashdi — fokus yangi qadam sarlavhasiga (ekran o'quvchi
  // qayerda ekanini eshitadi)
  const birinchi = useRef(true);
  useLayoutEffect(() => {
    if (birinchi.current) {
      birinchi.current = false;
      return;
    }
    legendRef.current?.focus();
  }, [qadam]);

  async function keyingi() {
    const maydonlar = MAYDONLAR[qadam] ?? [];
    // trigger — faqat shu maydonlar; true — xato yo'q
    if (await trigger([...maydonlar], { shouldFocus: true })) {
      setQadam((q) => q + 1);
    }
  }

useLayoutEffect — fokus ekran chizilishidan oldin qo'yiladi, sakrash ko'rinmaydi. birinchi — sahifa ochilganda fokus sarlavhaga emas, Qobiq qo'ygan joyda qolsin.

Qadamlar ro'yxati — useFieldArray (kanon):

tsx
  // useFieldArray (19/#33): ro'yxat — massiv; har elementga
  // barqaror id (key uchun — indeks emas: o'rtadagisi o'chsa, holat
  // adashmaydi)
  const { fields, append, remove } = useFieldArray({
    control,
    name: "qadamlar",
  });

Har qator — register(\qadamlar.${i}.matn`), o'chirish tugmasi aria-label={`O'chirish: ${i + 1}-qadam`}vadisabled={fields.length === 1}— oxirgi qatorni o'chirib bo'lmaydi. Massiv xatosi —errors.qadamlar?.root?.message ?? errors.qadamlar?.message`.

Yaratish — qadamlar ketma-ket POST (mutatsiyalar dagi useYozuv ichida), jarayon matni "Qo'shilmoqda: 2 / 3" (role="status"). Server id'larni tartib bilan beradi, shuning uchun parallel emas. Tugagach — e'lon va /?filtr=faol ga o'tish.

6.4 Marshrut va havola

marshrutlar.tsx va App.tsx (kanon):

diff
+import { YangiLoyiha } from "./sahifalar/YangiLoyiha.tsx";
...
+          // 19/#33: ko'p qadamli forma (wizard)
+          {
+            path: "yangi",
+            element: <YangiLoyiha />,
+            handle: nomi("Qadamlarga bo'lib"),
+          },
diff
+      {/* 19/#33: katta ish — qadamlarga bo'lib (ko'p qadamli
+          forma) */}
+      <p className="izoh">
+        <Link to="/yangi" id="usta-havola">
+          Katta ishni qadamlarga bo'lib qo'shish…
+        </Link>
+      </p>

6.5 Tekshiruvlar va commit

Yangi tekshiruv/loyiha-formasi.test.js — sxemaning 5 testi. Natijalar (haqiqiy, holat 33):

  • npm test — node:test ℹ tests 187 · ℹ pass 187 (182 + 5), Vitest Tests 64 passed (64). «Orqaga» tuzatishidan keyin sizda node:test — 195 (190 + 5).
  • Build: index-….js 473.89 kB │ gzip: 150.83 kB — +3.3 KB (yangi sahifa; kutubxona yangi emas).
  • Brauzer: 159/159, 72/72, 19-qism — 85 (tuzatishlardan keyin 88). Wizard bo'limi:
text
✅ 1-qadam: teg noto'g'ri — xato, fokus maydonda, qadam o'zgarmadi: ["Teg # bilan boshlansin, bo'shliqsiz: #ta'mir.","usta-teg","Teg"]
✅ 2-qadam: fokus qadam sarlavhasida (legend): ["LEGEND","2/3: Qadamlar"]
✅ useFieldArray: qo'shildi — fokus yangi maydonda: "usta-qadam-1"
✅ async: serverda bor («Non olish #bozor») — o'sha qadamga xato; serverdan yangi ro'yxat so'raldi: ["«Non olish #bozor» ro'yxatda allaqachon bor.",["GET"]]
✅ 3-qadam: ko'rib chiqish: ["Sabzi olish #bozor"]
✅ yaratildi: POST, ro'yxat (faol), e'lon: [true,"/?filtr=faol","1 ta vazifa qo'shildi (#bozor).",true]
✅ wizard: CSP buzilishi yo'q: []

To'rtinchi qator — async tekshiruvning isboti: so'rov bitta (["GET"]), xato aynan o'sha qadam ostida.

Bir "yon" topilma ham bo'ldi. Eski "qidiruv: filtr bilan birga" tekshiruvi (13–17-qismdan) holat 33 da uch ishga tushirishdan ikkitasida yiqildi. Sabab: filtr URL holati darsidan beri router navigatsiyasi — asinxron, ro'yxat bir zum eski edi. Tekshiruv endi URL va ro'yxat yangilanishini kutadi (kutilgan qiymat o'zgarmagan). Holat 32 da u tasodifan o'tib kelgan. Diff: 6 fayl, +482.

bash
git add -A
git commit

Commit (kanon): feat: /yangi — ko'p qadamli forma (useFieldArray, async tekshiruv).

useBlocker va avtosaqlash kanonda yo'q. Wizard qisqa (uch qadam), ma'lumot kichik, va 18-qismdan beri ro'yxat formasining o'z qoralamasi bor. Ularni 3-mashqda o'zingiz qo'shasiz.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
qator o'chirilgach boshqa qatorning holati "ko'chdi" key={i} key={field.id}
yozilgan qiymat fields[i] da yo'q fields — boshlang'ich qiymatlar useWatch / getValues
"Keyingi" hali ko'rinmagan maydonlarni ham xato deydi trigger() argumentsiz trigger(["teg"])
oxirgi qadamda oldingi qiymatlar yo'q shouldUnregister: true sukutni qoldiring (false)
har harfda serverga so'rov async validate + mode: "onChange" onBlur / debounce
eski javob yangisini bosib ketdi parallel so'rovlar tartibsiz qaytdi onBlur, yoki faqat oxirgi so'rov natijasini oling
useBlocker must be used within a data router <BrowserRouter> createBrowserRouter + RouterProvider
sahifa qaytganda sekin ochiladi beforeunload doim ulangan faqat isDirty bo'lganda ulang

8. Mashqlar

Mashqlar kurs/mashqlar/19/33-dinamik/ da. Har yechimni «Ishga tushir» bilan sinang.

1-mashq (oson): qaysi biri?

  • Massiv maydonida key uchun →
  • Wizard'da faqat joriy qadamni tekshiradigan metod →
  • Ilova ichida chiqib ketishni to'xtatadigan React Router hook'i →
  • Tab yopilayotganda ogohlantiradigan brauzer hodisasi →
  • Async tekshiruvda har harfda so'rov ketmasligi uchun eng sodda mode →
Yechim
  • field.id — RHF bergan barqaror kalit, qiymat bilan birga ko'chadi.
  • trigger — trigger(["teg"]) faqat shu maydonlarni tekshiradi, true/false qaytaradi.
  • useBlocker — faqat data router bilan; proceed() / reset().
  • beforeunload — matnni brauzer o'zi tanlaydi.
  • onBlur (yoki onSubmit + reValidateMode: "onBlur").

2-mashq (o'rta): telefon raqamlari ro'yxati

Bron formasida mehmon bir nechta telefon qoldira olsin:

  1. Boshida bitta bo'sh qator; "Raqam qo'shish" — yangi qator; har qatorda "O'chirish: N-raqam" (oxirgisi o'chmaydi — disabled).
  2. Ko'pi bilan 3 ta raqam — "Raqam qo'shish" uchinchisidan keyin disabled.
  3. «Saqlash» — konsolga raqamlar massivi.
jsx
import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit } = useForm({
    defaultValues: { phones: [{ number: "" }] },
  });
  return (
    <form onSubmit={handleSubmit((d) => console.log(d.phones))}>
      <input aria-label="1-raqam" {...register("phones.0.number")} />
      <button>Saqlash</button>
    </form>
  );
}

Konsolda:

text
(1) [{number: ''}]
Yechim
jsx
import { useFieldArray, useForm } from "react-hook-form";

const MAX = 3;

export default function App() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { phones: [{ number: "" }] },
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: "phones",
  });
  return (
    <form onSubmit={handleSubmit((d) => console.log(d.phones))}>
      <ol>
        {fields.map((field, i) => (
          <li key={field.id}>
            <input
              aria-label={`${i + 1}-raqam`}
              {...register(`phones.${i}.number`)}
            />
            <button
              type="button"
              aria-label={`O'chirish: ${i + 1}-raqam`}
              disabled={fields.length === 1}
              onClick={() => remove(i)}
            >
              ✕
            </button>
          </li>
        ))}
      </ol>
      <button
        type="button"
        disabled={fields.length >= MAX}
        onClick={() => append({ number: "" })}
      >
        Raqam qo'shish
      </button>
      <button>Saqlash</button>
    </form>
  );
}

Konsolda:

text
(3) [{number: '+998 90 000 00 00'}, {number: '+998 91 000 00 00'}, {number: ''}]

Uchinchi qator qo'shildi va bo'sh qoldi — "Raqam qo'shish" endi o'chgan. Bo'sh raqamlarni yuborishdan oldin tashlash yoki xato berish — sxemaning ishi: z.array(z.object({ number: … })).min(1) va bo'shlarini transform bilan olib tashlash (Zod chuqurroq).

3-mashq (qiyin): taom tahriri — chiqishdan himoya

«Bahor» xodimi taom narxini tahrirlaydi:

  1. Ikki sahifa (xotiradagi router): / — "Osh tahriri" havolasi, /osh — forma (price, boshlang'ich "35000"), "Menyuga" havolasi va «Saqlash».
  2. O'zgarish bo'lsa va «Saqlash» bosilmagan bo'lsa — "Menyuga" bosilganda useBlocker oynasi: "Saqlanmagan narx bor" + "Chiqish" / "Qolish".
  3. «Saqlash» — konsolga saqlandi: <narx>, keyin reset(data): endi chiqish so'ralmaydi.

Ishora: useBlocker(isDirty). reset dan keyin isDirty — false (RHF II 3-mashqi).

Yechim
jsx
import { useForm } from "react-hook-form";
import {
  createMemoryRouter,
  Link,
  RouterProvider,
  useBlocker,
} from "react-router";

function EditDish() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { isDirty },
  } = useForm({ defaultValues: { price: "35000" } });
  const blocker = useBlocker(isDirty);

  function save(data) {
    console.log("saqlandi:", data.price);
    reset(data);
  }

  return (
    <form onSubmit={handleSubmit(save)}>
      <p>
        <label htmlFor="price">Narxi</label>
        <input id="price" {...register("price")} />
      </p>
      <button>Saqlash</button> <Link to="/">Menyuga</Link>
      {blocker.state === "blocked" && (
        <div role="alertdialog" aria-label="Chiqishni tasdiqlang">
          <p>Saqlanmagan narx bor</p>
          <button type="button" onClick={() => blocker.proceed()}>
            Chiqish
          </button>
          <button type="button" onClick={() => blocker.reset()}>
            Qolish
          </button>
        </div>
      )}
    </form>
  );
}

const router = createMemoryRouter([
  { path: "/", element: <Link to="/osh">Osh tahriri</Link> },
  { path: "/osh", element: <EditDish /> },
]);

export default function App() {
  return <RouterProvider router={router} />;
}

Konsolda:

text
saqlandi: 37000

Ekranda:

text
Osh tahriri

Butun yo'l: narx o'zgardi → "Menyuga" → oyna → "Qolish" (forma joyida) → «Saqlash» → reset → "Menyuga" — endi so'ramasdan menyuga o'tdi.

4-mashq: Vazifalar qadami

feat/qadamlarga-bolish branch'ida sxemalar/loyiha-formasi.ts (testi bilan), sahifalar/YangiLoyiha.tsx, marshrut va ro'yxatdagi havolani qo'shing. Commit.

Yechim

Tekshiruv ro'yxati:

  • npm test — node:test 195 («Orqaga» tuzatishigacha — 187), Vitest 64.
  • npm run dev → /yangi: teg ta'mir (#siz) → "Keyingi" — xato, fokus maydonda. #ta'mir → 2-qadam, fokus "2/3: Qadamlar" sarlavhasida.
  • "Qadam qo'shish" — fokus yangi maydonda. Mavjud vazifa matnini qadam qilib yozing ("Non olish", teg #bozor) → "Keyingi" — xato o'sha qator ostida; DevTools Network'da bitta GET.
  • "Yaratish" — POST'lar ketma-ket, keyin ro'yxat ?filtr=faol bilan, e'lon "N ta vazifa qo'shildi (#teg)".
  • git log --oneline -1 — feat: /yangi — ko'p qadamli forma (useFieldArray, async tekshiruv).

9. Real ishda

  • Wizard'lar — ro'yxatdan o'tish, kredit arizasi, onlayn-do'kon checkout'i (manzil → yetkazish → to'lov). Ko'pincha har qadam URL'da ham (/checkout/manzil) — orqaga tugmasi va sahifa yangilanishi uchun (URL — holat joyi).
  • Dinamik maydonlar — hisob-faktura qatorlari, so'rovnoma savollari, oila a'zolari. Katta ro'yxatda (100+ qator) har qatorni alohida komponentga va useWatch ga ajratish muhim.
  • Async tekshiruv — login/email bandligi, promokod, INN. Har doim serverda qayta tekshiriladi; brauzerdagi — qulaylik.
  • Intervyu savollari: "useFieldArray da nega field.id?", "ko'p qadamli formani qanday qurasiz?", "async validatsiyada so'rovlar sonini qanday kamaytirasiz?", "foydalanuvchi saqlamay chiqib ketsa nima qilasiz?".

Xulosa

  • useFieldArray — fields (key={field.id}), append, remove, move; nomlar items.${i}.dish; joriy qiymat — useWatch.
  • Wizard — bitta useForm, har qadamda trigger([...]); qiymatlar unmount'dan keyin ham qoladi; qadam almashganda fokus sarlavhaga.
  • Async tekshiruv — validate yoki superRefine; so'rov — onBlur/debounce bilan kam, avval arzon qoidalar. Kafolat — server.
  • Saqlanmagan o'zgarish: ilova ichida — useBlocker(isDirty), tab yopilganda — beforeunload (faqat isDirty da); eng yaxshisi — avtosaqlash.

Keyingi dars: Validatsiya va forma kutubxonalari muqobillari — Formik, TanStack Form, Conform, Valibot va Standard Schema: bitta formani besh xil yozib, hajmini o'lchaymiz.

Manbalar

  • React Hook Form: useFieldArray, trigger, register (validate), formState.isValidating — react-hook-form.com/docs (2026-10 holatiga, v7.89.0)
  • React Router: useBlocker, useBeforeUnload — reactrouter.com/api/hooks/useBlocker (v8.4.0)
  • MDN: beforeunload hodisasi va bfcache — developer.mozilla.org/docs/Web/API/Window/beforeunload_event
  • WAI-ARIA: aria-current="step" — w3.org/TR/wai-aria-1.2/#aria-current
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
React Hook Form useFieldArray, ko'p qadamli forma va async validatsiya — IlmHamroh