React / Эцсийн төсөл

Эцсийн төсөл

Та React курсын 44 хичээлийг дуусгасан — component, state, hooks, TypeScript, тест, pattern бүгдийг сурсан. Одоо эдгээрийг нэгтгэж бодит апп бүтээх цаг боллоо. Энэ төслийн зорилго нь ганцхан зүйл: сурсан мэдлэгээ гараараа хийж батлах.

Юу бүтээх вэ?

Хувийн номын сан — хэрэглэгч ном нэмж, уншсан эсэхийг тэмдэглэж, дуртай номоо хадгалдаг апп. Жижиг боловч бодит хэрэглээний бүх шаардлагыг хангасан суурь төсөл.

Аппын үндсэн чадварууд:

код
✓ Ном нэмэх, засах, устгах
✓ "Уншсан" / "Унших гэж байна" / "Уншиж байна" гэсэн төлвүүд
✓ Нэр, зохиолч, жанраар шүүх
✓ Local storage-д хадгалах — хуудас дахин ачаалсан ч хэвээр байх
✓ TypeScript — бүх өгөгдлийн бүтэц тодорхой
✓ Vitest-р нэг custom hook-ын тест

Өгөгдлийн бүтэц

Эхлээд төрлүүдийг тодорхойлно — TypeScript-н хамгийн чухал алхам:

typescript
// src/types/book.ts

export type ReadStatus = "хүлээгдэж_буй" | "уншиж_байна" | "уншсан";

export interface Book {
  id: string;
  title: string;
  author: string;
  genre: string;
  status: ReadStatus;
  rating: number | null; // 1–5 од, уншаагүй бол null
  addedAt: number; // timestamp
  finishedAt: number | null;
}

export interface BookFilter {
  search: string;
  genre: string | null;
  status: ReadStatus | null;
}

useBooks custom hook

Апп-н бүх логик нэг custom hook-д:

typescript
// src/hooks/useBooks.ts
import { useState, useEffect, useCallback, useMemo } from "react";
import type { Book, BookFilter, ReadStatus } from "../types/book";

const STORAGE_KEY = "my-library-books";

function generateId(): string {
  return `book-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}

export function useBooks() {
  const [books, setBooks] = useState<Book[]>(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY);
      return saved ? (JSON.parse(saved) as Book[]) : [];
    } catch {
      return [];
    }
  });

  const [filter, setFilter] = useState<BookFilter>({
    search: "",
    genre: null,
    status: null,
  });

  // books өөрчлөгдөх бүрт localStorage-д хадгал
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(books));
  }, [books]);

  const addBook = useCallback(
    (data: Omit<Book, "id" | "addedAt" | "finishedAt" | "rating">) => {
      const newBook: Book = {
        ...data,
        id: generateId(),
        rating: null,
        addedAt: Date.now(),
        finishedAt: null,
      };
      setBooks((prev) => [newBook, ...prev]);
    },
    [],
  );

  const updateStatus = useCallback((id: string, status: ReadStatus) => {
    setBooks((prev) =>
      prev.map((b) =>
        b.id === id
          ? {
              ...b,
              status,
              finishedAt: status === "уншсан" ? Date.now() : null,
            }
          : b,
      ),
    );
  }, []);

  const rateBook = useCallback((id: string, rating: number) => {
    setBooks((prev) => prev.map((b) => (b.id === id ? { ...b, rating } : b)));
  }, []);

  const deleteBook = useCallback((id: string) => {
    setBooks((prev) => prev.filter((b) => b.id !== id));
  }, []);

  // Шүүлт болон хайлт — useMemo-р кэшлэнэ
  const filteredBooks = useMemo(() => {
    return books.filter((b) => {
      const matchSearch =
        filter.search === "" ||
        b.title.toLowerCase().includes(filter.search.toLowerCase()) ||
        b.author.toLowerCase().includes(filter.search.toLowerCase());
      const matchGenre = filter.genre === null || b.genre === filter.genre;
      const matchStatus = filter.status === null || b.status === filter.status;
      return matchSearch && matchGenre && matchStatus;
    });
  }, [books, filter]);

  const stats = useMemo(
    () => ({
      total: books.length,
      read: books.filter((b) => b.status === "уншсан").length,
      reading: books.filter((b) => b.status === "уншиж_байна").length,
      pending: books.filter((b) => b.status === "хүлээгдэж_буй").length,
    }),
    [books],
  );

  return {
    books: filteredBooks,
    stats,
    filter,
    setFilter,
    addBook,
    updateStatus,
    rateBook,
    deleteBook,
  };
}

Апп-н бүтэц

код
src/
├── types/
│   └── book.ts
├── hooks/
│   ├── useBooks.ts
│   └── useBooks.test.ts      ← hook-н тест
├── components/
│   ├── BookCard/
│   │   ├── BookCard.tsx
│   │   └── BookCard.stories.tsx
│   ├── AddBookForm/
│   │   └── AddBookForm.tsx
│   ├── FilterBar/
│   │   └── FilterBar.tsx
│   └── StatsBar/
│       └── StatsBar.tsx
└── App.tsx

App.tsx нь useBooks hook дуудаж, бүх component-д тохирох props дамжуулна — Provider шаардлагагүй, учир нь state нэг хэсэгт л байна.

Hook-н тест

typescript
// src/hooks/useBooks.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "vitest";
import { useBooks } from "./useBooks";

describe("useBooks hook", () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it("эхэндээ хоосон байна", () => {
    const { result } = renderHook(() => useBooks());
    expect(result.current.books).toHaveLength(0);
    expect(result.current.stats.total).toBe(0);
  });

  it("ном нэмэхэд жагсаалтад орно", () => {
    const { result } = renderHook(() => useBooks());

    act(() => {
      result.current.addBook({
        title: "Цэнхэр толь",
        author: "Д.Нацагдорж",
        genre: "Яруу найраг",
        status: "хүлээгдэж_буй",
      });
    });

    expect(result.current.books).toHaveLength(1);
    expect(result.current.books[0].title).toBe("Цэнхэр толь");
    expect(result.current.stats.total).toBe(1);
    expect(result.current.stats.pending).toBe(1);
  });

  it("төлөв солиход stats шинэчлэгдэнэ", () => {
    const { result } = renderHook(() => useBooks());

    act(() => {
      result.current.addBook({
        title: "Тест ном",
        author: "Зохиолч",
        genre: "Роман",
        status: "хүлээгдэж_буй",
      });
    });

    const id = result.current.books[0].id;

    act(() => {
      result.current.updateStatus(id, "уншсан");
    });

    expect(result.current.stats.read).toBe(1);
    expect(result.current.stats.pending).toBe(0);
    expect(result.current.books[0].finishedAt).not.toBeNull();
  });
});

Цааш хөгжүүлэх санаанууд

Суурь апп ажилласны дараа нэмж болох зүйлс:

код
□ react-router нэмж /library, /add, /book/:id маршрутууд
□ Supabase-тай холбож өгөгдлийг cloud-д хадгалах
□ Drag-and-drop Kanban самбар (Unread → Reading → Done)
□ Ном хайх Google Books API-тай холбох (үнэгүй)
□ Стройбük-д BookCard-н бүх story нэмэх
□ E2E тест Playwright-аар бичих

Энэ апп дуусвал GitHub-д байршуулаад README-д "React, TypeScript, Vitest, Storybook ашигласан" гэж бич — ажлын байранд CV-д тавихад тохиромжтой бодит төсөл болно.

Курс дуусгасанд баяр хүргэе!

45 хичээл дуусгасан — component-аас эхлэн TypeScript, тест, архитектурын pattern хүртэл React-н чухал бүх хэсгийг дамжлаа. Хамгийн чухал алхам дараагийнх: сурсан зүйлсээ бодит төсөлд хэрэглэх. Код бичих нь цорын ганц арга — ном уншиж, видео үзэж дэлгэрэнгүй мэдэх нь практиктай тэнцэхгүй. Цааш суралцах бол Next.js курсыг шалгаарай — React-н суурин дээр full-stack апп бүтээх дараагийн алхам тэнд байгаа.

Дараагийн хичээлд:

Энэ курс дуусгасан таны дараагийн алхам бол Next.js үндэс курс — React мэдлэгийн дээр сервер side rendering, file-based routing, Supabase-тай full-stack апп бүтээх ур чадвар нэмэгдэнэ.