React / Zustand state management

Zustand state management

Апп томрох тусам олон component дундаа state хуваалцах хэрэгтэй болдог. useState нь нэг component-д, useContext нь дунд хэмжээний хуваалцсан state-д тохиромжтой. Гэхдээ маш их газарт өөрчлөгдөх глобал state-д Zustand хамгийн зохистой сонголт. Ойлгоход хялбар, кодны хэмжээ бага, хурдан!

Яагаад Zustand вэ?

Redux нь хүчирхэг боловч их boilerplate code шаарддаг. Context нь хялбар боловч гүйцэтгэлийн асуудал гардаг — value өөрчлөгдөх болгонд бүх subscriber дахин рендерлэгдэнэ. Zustand хоёрын давуу талыг хослуулсан:

bash
npm install zustand

Анхны store үүсгэх

jsx
import { create } from "zustand";

// Store нь state + action-уудыг агуулна
const useCounterStore = create((set) => ({
  count: 0,
  // set нь state-г шинэчлэх функц
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

// Ямар ч component-д provider хэрэггүйгээр шууд хэрэглэнэ
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const reset = useCounterStore((state) => state.reset);

  return (
    <div style={{ textAlign: "center", padding: "24px" }}>
      <p style={{ fontSize: "48px", fontWeight: "bold" }}>{count}</p>
      <div style={{ display: "flex", gap: "8px", justifyContent: "center" }}>
        <button onClick={decrement}></button>
        <button onClick={reset}>Дахин тохируулах</button>
        <button onClick={increment}>+</button>
      </div>
    </div>
  );
}

Provider хэрэгтэй гэж байхгүй — store-г аппын хаанаас ч шууд ашиглаж болно. useCounterStore((state) => state.count) нь зөвхөн count өөрчлөгдөхөд л дахин рендерлэдэг — гүйцэтгэл сайн байдгийн нууц энэ!

Бодит жишээ: сагс (cart store)

jsx
import { create } from "zustand";

const useCartStore = create((set, get) => ({
  items: [],
  // get() нь одоогийн state-г уншина
  addItem: (product) => {
    const items = get().items;
    const existing = items.find((i) => i.id === product.id);
    if (existing) {
      set({
        items: items.map((i) =>
          i.id === product.id ? { ...i, qty: i.qty + 1 } : i,
        ),
      });
    } else {
      set({ items: [...items, { ...product, qty: 1 }] });
    }
  },
  removeItem: (id) => set({ items: get().items.filter((i) => i.id !== id) }),
  updateQty: (id, qty) =>
    set({
      items:
        qty <= 0
          ? get().items.filter((i) => i.id !== id)
          : get().items.map((i) => (i.id === id ? { ...i, qty } : i)),
    }),
  clearCart: () => set({ items: [] }),
  // Computed утга — selector ашиглана
  get total() {
    return get().items.reduce((s, i) => s + i.price * i.qty, 0);
  },
  get itemCount() {
    return get().items.reduce((s, i) => s + i.qty, 0);
  },
}));

// Сагсны товчлуур — зөвхөн itemCount-г subscribe хийнэ
function CartIcon() {
  const itemCount = useCartStore((state) =>
    state.items.reduce((s, i) => s + i.qty, 0),
  );
  return (
    <div style={{ position: "relative", display: "inline-block" }}>
      🛒
      {itemCount > 0 && (
        <span
          style={{
            position: "absolute",
            top: "-8px",
            right: "-8px",
            background: "red",
            color: "white",
            borderRadius: "50%",
            width: "20px",
            height: "20px",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: "12px",
          }}
        >
          {itemCount}
        </span>
      )}
    </div>
  );
}

// Бүтээгдэхүүний карт
function ProductCard({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return (
    <div
      style={{ border: "1px solid #ccc", padding: "16px", borderRadius: "8px" }}
    >
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}₮</p>
      <button onClick={() => addItem(product)}>Сагсанд нэмэх</button>
    </div>
  );
}

Selector — зөвхөн хэрэгтэй хэсгийг subscribe хийх

Zustand-н гол давуу тал бол нарийн selector:

jsx
const useAuthStore = create((set) => ({
  user: null,
  token: null,
  isLoading: false,
  login: async (email, password) => {
    set({ isLoading: true });
    try {
      const res = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      const { user, token } = await res.json();
      set({ user, token, isLoading: false });
    } catch {
      set({ isLoading: false });
    }
  },
  logout: () => set({ user: null, token: null }),
}));

// Navbar — зөвхөн user.name-г сонирхоно
// user.name өөрчлөгдөхөд л дахин рендерлэгдэнэ
function Navbar() {
  const userName = useAuthStore((state) => state.user?.name);
  const logout = useAuthStore((state) => state.logout);

  return (
    <nav>
      {userName ? (
        <>
          <span>Сайн уу, {userName}!</span>
          <button onClick={logout}>Гарах</button>
        </>
      ) : (
        <a href="/login">Нэвтрэх</a>
      )}
    </nav>
  );
}

// LoginButton — зөвхөн isLoading-г сонирхоно
function LoginButton() {
  const isLoading = useAuthStore((state) => state.isLoading);
  const login = useAuthStore((state) => state.login);
  return (
    <button disabled={isLoading} onClick={() => login("test@test.com", "pass")}>
      {isLoading ? "Нэвтэрч байна..." : "Нэвтрэх"}
    </button>
  );
}

Navbar нь зөвхөн user?.name өөрчлөгдөхөд л дахин рендерлэгдэнэ — isLoading өөрчлөгдсөн ч Navbar хөдлөхгүй. Context ашигласан бол хоёулаа дахин рендерлэгдэх байсан.

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

React + TypeScript үндэс сурна — React component-уудыг TypeScript-ээр хэрхэн бичихийг ойлгоно. Props, state, event зэрэгт төрөл (type) тодорхойлох нь алдааг эрт илрүүлж, кодыг найдвартай болгодог.