09 / 09Proje
Ürün kataloğu ve sepet
Filtreleri store'da tutan, sorguyu filtreyle süren, slice'lardan oluşan kalıcı bir sepet ve sunucu verisinden türetilen stok uyarısı.
Senaryo
Bir mağazanın ürün kataloğunu ve sepetini kuracaksınız. Uygulama:
Kategori ve arama filtrelerini bir Zustand store'unda tutar; filtre değişince ürün sorgusu kendiliğinden yeniden çalışır.
Ürünleri TanStack Query ile çeker; filtre değişirken eski liste soluk olarak ekranda kalır.
Sepeti ve sepet çekmecesini slice'lardan oluşan tek bir store'da tutar. "Sepete ekle" hem ürünü ekler hem çekmeceyi açar.
Sepeti
localStorage'a yazar; sayfa yenilenince sepet geri gelir, çekmece kapalı açılır.Her değişikliği adıyla Redux DevTools'a gönderir.
Sepet listesini yalnızca ürün
id'lerini okuyarak çizer; bir ürünün adedi değişince yalnızca o satır render olur.Sepetteki adet güncel stoğu aşıyorsa uyarı gösterir. Uyarıyı store'u güncelleyerek değil, sunucu verisinden türeterek üretir.
Amaç tasarım değil, derslerdeki parçaların birlikte çalışmasıdır. Her parçanın hangi dersten geldiği:
| Parça | Dosya | Ders |
|---|---|---|
create<T>(), set, getInitialState ile sıfırlama | filter-store.ts | İlk store, Action ve güncelleme |
actions nesnesi, yalnızca custom hook export etmek | filter-store.ts, shop-store.ts | Action ve güncelleme, Selector ve yeniden render |
| Filtreyle sorguyu sürmek | filter-store.ts, queries.ts | TanStack Query ile birlikte kullanım |
| Sepet, arayüz ve ortak slice | shop-store.ts | Slice pattern |
persist, partialize, version, devtools | shop-store.ts | Persist ve devtools |
Atomik selector, useShallow ile id listesi | shop-store.ts, CartDrawer.tsx | Selector ve yeniden render |
| Stok uyarısını türetmek | CartDrawer.tsx | TanStack Query ile birlikte kullanım |
Projenin çalışan hali GitHub'da: examples/zustand/project. Akışları tarayıcıda deneyen Playwright testleri de orada; takıldığınızda karşılaştırın.
Adım 1: Projeyi Kurun
İlk store dersindeki gibi bir Vite projesi açın ve paketleri ekleyin:12
$ pnpm create vite@latest shop-catalog -- --template react-ts$ pnpm add zustand @tanstack/react-query$ pnpm add -D @redux-devtools/extensionsrc/App.tsx, src/App.css ve src/index.css dosyalarını silin. Tüm dosyaları src/project/ altına yazacaksınız.
Adım 2: Sahte API ve Sorgu
Gerçek bir sunucu yerine gecikmeli bir fonksiyon kullanın. Bir ürünün stoğu 0; "Sepete ekle" butonunun kapandığını görmek için.
// src/project/api.ts
export type Category = "shoes" | "clothing" | "accessories";
export type Product = {
id: number;
name: string;
category: Category;
price: number;
stock: number;
};
export type ProductFilters = { category: Category | "all"; search: string };
const products: Product[] = [
{ id: 1, name: "Canvas Sneaker", category: "shoes", price: 1200, stock: 5 },
{ id: 2, name: "Running Shoe", category: "shoes", price: 2100, stock: 3 },
{ id: 3, name: "Hiking Boot", category: "shoes", price: 2800, stock: 0 },
{ id: 4, name: "Linen Shirt", category: "clothing", price: 850, stock: 8 },
{ id: 5, name: "Denim Jacket", category: "clothing", price: 1650, stock: 2 },
{ id: 6, name: "Knit Sweater", category: "clothing", price: 1100, stock: 6 },
{ id: 7, name: "Wool Beanie", category: "accessories", price: 300, stock: 10 },
{ id: 8, name: "Leather Belt", category: "accessories", price: 450, stock: 4 },
{ id: 9, name: "Silk Scarf", category: "accessories", price: 600, stock: 1 },
];
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fetchProducts({ category, search }: ProductFilters): Promise<Product[]> {
await delay(300);
return products
.filter((p) => category === "all" || p.category === category)
.filter((p) => p.name.toLowerCase().includes(search.trim().toLowerCase()))
.map((p) => ({ ...p }));
}Sorgu ayarlarını queryOptions ve select dersindeki gibi bir factory'de toplayın. Filtreler anahtarın parçasıdır:
// src/project/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchProducts, type ProductFilters } from "./api";
export const productQueries = {
all: () => ["products"] as const,
list: (filters: ProductFilters) =>
queryOptions({
queryKey: [...productQueries.all(), "list", filters],
queryFn: () => fetchProducts(filters),
}),
};Adım 3: Filtre Store'u
Filtreler küçük ve bağımsız bir store'dur. Store'un kendisi export edilmez; dışarıya yalnızca custom hook'lar açılır (bkz. Selector ve yeniden render). Action'lar tek bir actions nesnesindedir ve reset, getInitialState() ile başlangıca döner (bkz. Action ve güncelleme).3
useFilteredProducts iki dünyayı birleştirir: filtreyi store'dan okur, query key'e koyar. Filtre değişince sorgu yeniden çalışır; kimse refetch çağırmaz (bkz. TanStack Query ile birlikte kullanım).3
// src/project/filter-store.ts
import { create } from "zustand";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { ProductFilters } from "./api";
import { productQueries } from "./queries";
type FilterState = ProductFilters & {
actions: {
setCategory: (category: ProductFilters["category"]) => void;
setSearch: (search: string) => void;
reset: () => void;
};
};
const useFilterStore = create<FilterState>()((set, _get, store) => ({
category: "all",
search: "",
actions: {
setCategory: (category) => set({ category }),
setSearch: (search) => set({ search }),
reset: () => set(store.getInitialState()),
},
}));
export const useCategory = () => useFilterStore((state) => state.category);
export const useSearch = () => useFilterStore((state) => state.search);
export const useFilterActions = () => useFilterStore((state) => state.actions);
export function useFilteredProducts() {
const category = useCategory();
const search = useSearch();
return useQuery({
...productQueries.list({ category, search }),
placeholderData: keepPreviousData,
});
}Adım 4: Sepet Store'u
Sepet ve çekmece birbirini etkiler: ürün ekleyince çekmece açılır. Bu yüzden Slice pattern dersindeki gibi tek bir store'da, üç slice olarak duruyorlar.4
items, ürünid'sine göre bir nesnedir. Bir ürünü bulmak ya da güncellemek için diziyi taramak gerekmez.setQuantityadet1'in altına düşünce ürünü sepetten çıkarır.persistyalnızcaitems'ı saklar; çekmecenin açık olması kalıcı değildir.5devtoolsen dıştadır; hersetçağrısı paneldecart/addItemgibi bir adla görünür.6Middleware kullanıldığı için slice tipleri
Mutatorslistesini alır.4
// src/project/shop-store.ts
import { create, type StateCreator } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { useShallow } from "zustand/react/shallow";
import type { Product } from "./api";
export type CartItem = Pick<Product, "id" | "name" | "price"> & { quantity: number };
type CartSlice = {
items: Record<number, CartItem>;
addItem: (product: Product) => void;
setQuantity: (id: number, quantity: number) => void;
removeItem: (id: number) => void;
clearCart: () => void;
};
type UiSlice = {
isCartOpen: boolean;
openCart: () => void;
closeCart: () => void;
};
type SharedSlice = {
addToCart: (product: Product) => void;
};
type ShopState = CartSlice & UiSlice & SharedSlice;
type Mutators = [["zustand/devtools", never], ["zustand/persist", unknown]];
const createCartSlice: StateCreator<ShopState, Mutators, [], CartSlice> = (set) => ({
items: {},
addItem: (product) =>
set(
(state) => {
const quantity = (state.items[product.id]?.quantity ?? 0) + 1;
const { id, name, price } = product;
return { items: { ...state.items, [id]: { id, name, price, quantity } } };
},
undefined,
"cart/addItem",
),
setQuantity: (id, quantity) =>
set(
(state) => {
const item = state.items[id];
if (!item) return {};
if (quantity < 1) {
const { [id]: _removed, ...rest } = state.items;
return { items: rest };
}
return { items: { ...state.items, [id]: { ...item, quantity } } };
},
undefined,
"cart/setQuantity",
),
removeItem: (id) =>
set(
(state) => {
const { [id]: _removed, ...rest } = state.items;
return { items: rest };
},
undefined,
"cart/removeItem",
),
clearCart: () => set({ items: {} }, undefined, "cart/clearCart"),
});
const createUiSlice: StateCreator<ShopState, Mutators, [], UiSlice> = (set) => ({
isCartOpen: false,
openCart: () => set({ isCartOpen: true }, undefined, "ui/openCart"),
closeCart: () => set({ isCartOpen: false }, undefined, "ui/closeCart"),
});
const createSharedSlice: StateCreator<ShopState, Mutators, [], SharedSlice> = (_set, get) => ({
addToCart: (product) => {
get().addItem(product);
get().openCart();
},
});
const useShopStore = create<ShopState>()(
devtools(
persist(
(...a) => ({
...createCartSlice(...a),
...createUiSlice(...a),
...createSharedSlice(...a),
}),
{
name: "shop-cart",
version: 1,
partialize: (state) => ({ items: state.items }),
},
),
{ name: "ShopStore" },
),
);
export const useCartItemIds = () =>
useShopStore(useShallow((state) => Object.keys(state.items).map(Number)));
export const useCartItem = (id: number) => useShopStore((state) => state.items[id]);
export const useCartQuantity = (id: number) =>
useShopStore((state) => state.items[id]?.quantity ?? 0);
export const useCartCount = () =>
useShopStore((state) =>
Object.values(state.items).reduce((sum, item) => sum + item.quantity, 0),
);
export const useCartTotal = () =>
useShopStore((state) =>
Object.values(state.items).reduce((sum, item) => sum + item.price * item.quantity, 0),
);
export const useIsCartOpen = () => useShopStore((state) => state.isCartOpen);
export const useShopActions = () =>
useShopStore(
useShallow((state) => ({
addToCart: state.addToCart,
setQuantity: state.setQuantity,
removeItem: state.removeItem,
clearCart: state.clearCart,
openCart: state.openCart,
closeCart: state.closeCart,
})),
);useCartItemIds yeni bir dizi döndürdüğü için useShallow ile sarılı. Sarmasaydınız v5'te sayfa "Maximum update depth exceeded" hatasıyla dururdu.7 useShopActions da yeni bir nesne döndürür; action'lar hiç değişmediği için useShallow ile her zaman aynı referans gelir.
useCartQuantity sepette olmayan ürün için 0 döndürür. Bu sabit bir sayı olduğu için güvenlidir; [] ya da {} olsaydı yeni referans üretirdi.
Adım 5: Filtreler ve Ürünler
Fiyatı biçimlendiren küçük bir yardımcı:
// src/project/format.ts
export const formatPrice = (value: number) => `${value.toLocaleString("tr-TR")} ₺`;Filtre paneli yalnızca filtre store'unu tanır:
// src/project/Filters.tsx
import type { ProductFilters } from "./api";
import { useCategory, useFilterActions, useSearch } from "./filter-store";
const categories: { value: ProductFilters["category"]; label: string }[] = [
{ value: "all", label: "Tümü" },
{ value: "shoes", label: "Ayakkabı" },
{ value: "clothing", label: "Giyim" },
{ value: "accessories", label: "Aksesuar" },
];
export function Filters() {
const category = useCategory();
const search = useSearch();
const { setCategory, setSearch, reset } = useFilterActions();
return (
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<input
aria-label="Ürün ara"
placeholder="Ürün ara"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<select
aria-label="Kategori"
value={category}
onChange={(event) => setCategory(event.target.value as ProductFilters["category"])}
>
{categories.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
<button onClick={reset}>Filtreleri temizle</button>
</div>
);
}Ürün kartı, o ürünün sepetteki adedine abone olur. Başka bir ürünü sepete eklemek bu kartı render etmez. Stok sınırına gelince buton kapanır.
// src/project/ProductGrid.tsx
import type { Product } from "./api";
import { useFilteredProducts } from "./filter-store";
import { formatPrice } from "./format";
import { useCartQuantity, useShopActions } from "./shop-store";
function ProductCard({ product }: { product: Product }) {
const inCart = useCartQuantity(product.id);
const { addToCart } = useShopActions();
const soldOut = product.stock === 0;
const limitReached = inCart >= product.stock;
return (
<li style={{ border: "1px solid #ccc", padding: 12 }}>
<h3>{product.name}</h3>
<p>{formatPrice(product.price)}</p>
<p>{soldOut ? "Tükendi" : `Stok: ${product.stock}`}</p>
<button
onClick={() => addToCart(product)}
disabled={limitReached}
aria-label={`${product.name} sepete ekle`}
>
Sepete ekle
</button>
</li>
);
}
export function ProductGrid() {
const { data: products, isPending, isError, isPlaceholderData } = useFilteredProducts();
if (isPending) return <p>Ürünler yükleniyor...</p>;
if (isError) return <p>Ürünler yüklenemedi.</p>;
if (products.length === 0) return <p>Ürün bulunamadı.</p>;
return (
<ul
aria-label="Ürünler"
style={{
listStyle: "none",
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
opacity: isPlaceholderData ? 0.5 : 1,
}}
>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ul>
);
}Adım 6: Sepet Butonu ve Çekmece
Sepet butonu yalnızca toplam adedi okur:
// src/project/CartButton.tsx
import { useCartCount, useShopActions } from "./shop-store";
export function CartButton() {
const count = useCartCount();
const { openCart } = useShopActions();
return <button onClick={openCart}>Sepet ({count})</button>;
}Çekmecede iki ayrıntı var:
CartDraweryalnızcaidlistesini okur; herCartLinekendi kaydını seçer. Bir ürünün adedi değişince liste değil, yalnızca o satır render olur.CartLine, adedi Zustand'dan, güncel stoğu TanStack Query'den okur ve uyarıyı ikisinden türetir. Store'a "stok aşıldı" diye bir alan yazılmaz; stok değişince ya da adet azalınca uyarı kendiliğinden kaybolur.8
// src/project/CartDrawer.tsx
import { useQuery } from "@tanstack/react-query";
import { formatPrice } from "./format";
import { productQueries } from "./queries";
import {
useCartItem,
useCartItemIds,
useCartTotal,
useIsCartOpen,
useShopActions,
} from "./shop-store";
function useStock(id: number) {
const { data: products } = useQuery(productQueries.list({ category: "all", search: "" }));
return products?.find((p) => p.id === id)?.stock;
}
function CartLine({ id }: { id: number }) {
const item = useCartItem(id);
const stock = useStock(id);
const { setQuantity, removeItem } = useShopActions();
if (!item) return null;
const overStock = stock !== undefined && item.quantity > stock;
return (
<li>
<span>{item.name}</span> <span>{formatPrice(item.price * item.quantity)}</span>
<div>
<button
onClick={() => setQuantity(id, item.quantity - 1)}
aria-label={`${item.name} azalt`}
>
−
</button>
<span aria-label={`${item.name} adet`}> {item.quantity} </span>
<button
onClick={() => setQuantity(id, item.quantity + 1)}
disabled={stock !== undefined && item.quantity >= stock}
aria-label={`${item.name} artır`}
>
+
</button>
<button onClick={() => removeItem(id)} aria-label={`${item.name} kaldır`}>
Kaldır
</button>
</div>
{overStock && <p role="alert">Stokta yalnızca {stock} adet var.</p>}
</li>
);
}
export function CartDrawer() {
const isOpen = useIsCartOpen();
const ids = useCartItemIds();
const total = useCartTotal();
const { clearCart, closeCart } = useShopActions();
if (!isOpen) return null;
return (
<aside aria-label="Sepet" style={{ borderLeft: "1px solid #ccc", padding: 16 }}>
<h2>Sepet</h2>
{ids.length === 0 ? (
<p>Sepetiniz boş.</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{ids.map((id) => (
<CartLine key={id} id={id} />
))}
</ul>
)}
<p>Toplam: {formatPrice(total)}</p>
<button onClick={clearCart} disabled={ids.length === 0}>
Sepeti boşalt
</button>
<button onClick={closeCart}>Kapat</button>
</aside>
);
}Adım 7: Parçaları Birleştirin
// src/project/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
});// src/project/App.tsx
import { CartButton } from "./CartButton";
import { CartDrawer } from "./CartDrawer";
import { Filters } from "./Filters";
import { ProductGrid } from "./ProductGrid";
export function App() {
return (
<div style={{ display: "flex", gap: 24, padding: 24, fontFamily: "sans-serif" }}>
<main style={{ flex: 1 }}>
<header style={{ display: "flex", justifyContent: "space-between" }}>
<h1>Mağaza</h1>
<CartButton />
</header>
<Filters />
<ProductGrid />
</main>
<CartDrawer />
</div>
);
}// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./project/query-client";
import { App } from "./project/App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);npm run dev ile açın ve şu akışları deneyin:
Kategoriyi "Ayakkabı" yapın ve aramaya "run" yazın. Liste her değişiklikte güncelleniyor; "Filtreleri temizle" hepsini sıfırlıyor.
Bir ürünü sepete ekleyin. Çekmece açılıyor, buton "Sepet (1)" oluyor.
Silk Scarf'ı ekleyin. Stoğu
1olduğu için buton kapanıyor.Sayfayı yenileyin. Sepet duruyor, çekmece kapalı.
Redux DevTools'u açın ve bir ürünün adedini değiştirin. Panelde
cart/setQuantitygörünüyor.DevTools'un Application sekmesinde
shop-cartkaydındaki Denim Jacket adedini4yapın ve sayfayı yenileyin. Çekmecede stok uyarısı çıkıyor; adedi2'ye düşürünce kayboluyor.
Kendin Dene
useCartItemIds'denuseShallow'u kaldırın. Konsolda ne görüyorsunuz?partialize'ı silin, çekmeceyi açıp sayfayı yenileyin.CartItem'asizealanı ekleyin,version'ı2yapın ve eski kayıtlarımigrateilesize: "M"olacak şekilde taşıyın.Filtreleri store yerine URL'de (
?category=shoes&search=run) tutun. Hangi dosyalar değişiyor?CartLine'aconsole.log("render")koyun ve bir ürünün adedini değiştirin. Kaç satır loglanıyor?
Kaynaklar
Kurulum ve ilk store.
Redux DevTools bağlantısı ve `@redux-devtools/extension` paketi.
: — 20 Kasım 2022. Custom hook, `actions` nesnesi ve store'u `useQuery` ile birleştirmek.
`StateCreator` ile tipli slice'lar ve mutator listesi.
`partialize`, `version` ve kayıt biçimi.
`devtools`'u en dışa koymak.
Kararlı selector çıktısı ve `useShallow`.
: — 1 Eylül 2025. Client state'i sunucu verisiyle senkronlamak yerine türetmek.
Son güncelleme tarihi: 21 Ekim 2026
