Yasin Ateş

React’te Compound Component Pattern (Zustand ile)

React’te Compound Component Pattern (Zustand ile)

React projelerinde component’ler büyüdükçe prop listeleri de büyür. Bir süre sonra bir component’e 10–15 tane prop geçtiğimizi fark ederiz ve “bir dakika, burada bir şeyler yanlış gidiyor” deriz. İşte Compound Component Pattern tam da bu soruna zarif bir çözüm sunuyor. Bu yazıda pattern’ı sıfırdan anlayıp, Zustand ile birlikte gerçek bir Tab komponenti inşa edeceğiz.

💡 Not: Bu yazıda state yönetimi için Zustand kullandım. Zustand’ın en büyük avantajlarından biri Provider ve Context olmadan hook based çalışabilmesi. Ancak isterseniz aynı pattern’ı React’in Context API’si ile de uygulayabilirsiniz

🤔 Compound Component Pattern Ne İş Yapar?

Compound Component Pattern, birden fazla component'in birlikte çalışarak tek bir iş yapmasını sağlayan bir React design pattern'ıdır. Bu component'ler arasında paylaşılan bir state vardır ve bu state dışarıdan görünmez. Yani "implicit state" dediğimiz yapıyla çalışır.

HTML'den aslında bu pattern'a çok aşinayız. <select> ve <option> elementlerini düşünelim:

<select>
 <option value="react">React</option>
 <option value="vue">Vue</option>
 <option value="angular">Angular</option>
</select>

Burada <select> hangi <option>'ın seçili olduğunu biliyor, <option>'lar da kendilerinin seçili olup olmadığını biliyor. Ama biz bu state'i dışarıdan yönetmiyoruz. Her şey arkaplanda, "implicit" olarak gerçekleşiyor.

React'te de aynı mantığı component'lerimize uygulayabiliyoruz:

<Tabs>
 <Tabs.Tab label="Profil">
 <ProfilIcerigi />
 </Tabs.Tab>
 <Tabs.Tab label="Ayarlar">
 <AyarlarIcerigi />
 </Tabs.Tab>
</Tabs>

Bakın burada Tabs component'ine hangi tab'ın aktif olduğunu söylemedik. Tabs.Tab'a da "sen aktifsin" gibi bir prop geçmedik. Her şey component'lerin kendi aralarındaki iletişimle çözülüyor.

🎯 Prop Drilling Problemi ve Pattern'ın Getirdiği Çözüm

Prop Drilling çözümü — Global state Zustand

Diyelim ki bir Tab component'i yazıyoruz ve pattern kullanmıyoruz. Kod büyük ihtimalle şöyle bir şeye dönüşür:

<TabContainer
 tabs={["Profil", "Ayarlar", "Bildirimler"]}
 activeTab={activeTab}
 onTabChange={setActiveTab}
 tabStyle="underline"
 tabActiveColor="#3b82f6"
 tabInactiveColor="#6b7280"
 contentPadding="16px"
 showIcon={true}
 icons={[ProfilIcon, AyarlarIcon, BildirimIcon]}
 disabled={[false, false, true]}
/>

Bu component'e baktığımızda hemen birkaç sorun görüyoruz:

Prop soup (prop çorbası ):

İngilizce kaynaklarda “Prop soup” diye geçiyor olsa da “Prop cehennemi” de diyebiliriz kaba tabirle 😄

Component’in prop listesi her yeni özellikte büyüyor. Bir süre sonra hangi prop’un ne işe yaradığını takip etmek zorlaşıyor.

Esneklik sıfır: Tab'ların sırasını değiştirmek, birinin içine özel bir component koymak, ya da bir tab'ı koşullu render etmek istediğimizde ciddi refactoring gerekiyor.

Tekrar kullanılabilirlik düşük: Bu component başka bir projede farklı bir yapıyla kullanmak istediğimizde, ya prop listesini genişletmemiz ya da yeni bir component yazmamız gerekiyor.

Compound Component Pattern ile aynı Tab component'ini şöyle yazabiliyoruz:

<Tabs defaultTab="profil">
 <Tabs.List>
 <Tabs.Trigger value="profil">Profil</Tabs.Trigger>
 <Tabs.Trigger value="ayarlar">Ayarlar</Tabs.Trigger>
 <Tabs.Trigger value="bildirimler" disabled>Bildirimler</Tabs.Trigger>
 </Tabs.List>

 <Tabs.Content value="profil">
 <ProfilIcerigi />
 </Tabs.Content>
 <Tabs.Content value="ayarlar">
 <AyarlarIcerigi />
 </Tabs.Content>
</Tabs>

Böylece component’in yapısı okunabilir, esnek ve her parça kendi sorumluluğunu taşıyor.

🛠️ Şimdi Tab Componentini Yapalım

Zustand ile Tab

Adım 1: Zustand Store'unu Oluşturalım

İlk iş olarak Tab component'imizin state'ini yönetecek bir Zustand store'u oluşturmamız gerekiyor. Bu store aktif tab bilgisini tutacak ve tab değiştirme işlemini yönetecek.

// useTabStore.js
import { create } from 'zustand'

const useTabStore = create((set) => ({
 activeTab: '',
 setActiveTab: (tab) => set({ activeTab: tab }),
}))

export default useTabStore

★ useTabStore direkt bir hook. Hiçbir yerde Provider ile sarmamıza gerek yok

★ activeTab o an hangi tab'ın görünür olduğunu tutuyor

★ setActiveTab aktif tab'ı değiştirmek için kullandığımız action

★ Zustand'ın set fonksiyonu state'i immutable şekilde güncelliyor

Adım 2: Ana Tabs Componenti

Şimdi ana Tabs component'ini yazalım. Bu component store'a default tab değerini set edecek ve alt component'leri saracak.

// Tabs.js
import { useEffect } from 'react'
import useTabStore from './useTabStore'

function Tabs({ defaultTab, children }) {
 const setActiveTab = useTabStore((state) => state.setActiveTab)

 useEffect(() => {
 if (defaultTab) {
 setActiveTab(defaultTab)
 }
 }, [])

 return (
 <div className="tabs-container">
 {children}
 </div>
 )
}

★ useEffect içinde defaultTab değerini store'a yazıyoruz. Bu sayede ilk renderda hangi tab'ın aktif olacağını belirliyoruz

★ Burada dikkat ederseniz, hiçbir Context Provider yok. Zustand store’u global olarak zaten erişilebilir durumda

★ children prop'u sayesinde alt component'leri olduğu gibi render ediyoruz

Adım 3: TabList Komponenti

Tab butonlarını saran container component'imizi yazalım:

// TabList.js
function TabList({ children }) {
 return (
 <div className="tab-list" role="tablist">
 {children}
 </div>
 )
}

★ TabList yapısal bir component. Kendi state'i yok, sadece tab butonlarını grupluyor

★ role="tablist" erişilebilirlik (accessibility) için eklendi. Ekran okuyucular bu alanı tab listesi olarak tanıyacak

Adım 4: TabTrigger Komponenti

Her bir tab butonunu temsil eden component:

// TabTrigger.js
import useTabStore from './useTabStore'

function TabTrigger({ value, disabled = false, children }) {
 const activeTab = useTabStore((state) => state.activeTab)
 const setActiveTab = useTabStore((state) => state.setActiveTab)

 const isActive = activeTab === value

 const handleClick = () => {
 if (!disabled) {
 setActiveTab(value)
 }
 }

 return (
 <button
 role="tab"
 className={`tab-trigger ${isActive ? 'active' : '’} ${disabled ? 'disabled' : '’}`}
 onClick={handleClick}
 disabled={disabled}
 >
 {children}
 </button>
 )
}

★ useTabStore((state) => state.activeTab) satırına dikkat edelim. Zustand'ın selector yapısını kullanıyoruz. Bu component sadece activeTab değiştiğinde render olacak, başka bir state değişikliğinden etkilenmeyecek

★ isActive kontrolüyle aktif tab'a farklı stil uyguluyoruz

★ disabled prop'u ile belirli tab'ları devre dışı bırakabiliyoruz

Adım 5: TabContent Komponenti

Her tab'ın içeriğini gösteren component:

// TabContent.js
import useTabStore from './useTabStore'

function TabContent({ value, children }) {
 const activeTab = useTabStore((state) => state.activeTab)

 if (activeTab !== value) {
 return null
 }

 return (
 <div role="tabpanel" className="tab-content">
 {children}
 </div>
 )
}

★ activeTab !== value kontrolüyle sadece aktif tab'ın içeriği DOM'da render ediliyor

★ Diğer tab içerikleri null döndüğü için gereksiz DOM element'leri oluşmuyor

★ role="tabpanel" yine erişilebilirlik için eklendi

Adım 6: Compound Yapısını Birleştirelim

Şimdi tüm parçaları birleştirip dışa aktaralım:

// Tabs.js (tamamlanmis hali)
import { useEffect } from 'react'
import { create } from 'zustand'

// Store
const useTabStore = create((set) => ({
 activeTab: '',
 setActiveTab: (tab) => set({ activeTab: tab }),
}))

// Ana component
function Tabs({ defaultTab, children }) {
 const setActiveTab = useTabStore((state) => state.setActiveTab)

 useEffect(() => {
 if (defaultTab) {
 setActiveTab(defaultTab)
 }
 }, [])

 return (
 <div className="tabs-container">
 {children}
 </div>
 )
}

// Alt component'ler
function TabList({ children }) {
 return (
 <div className="tab-list" role="tablist">
 {children}
 </div>
 )
}

function TabTrigger({ value, disabled = false, children }) {
 const activeTab = useTabStore((state) => state.activeTab)
 const setActiveTab = useTabStore((state) => state.setActiveTab)
 const isActive = activeTab === value

 const handleClick = () => {
 if (!disabled) {
 setActiveTab(value)
 }
 }

 return (
 <button
 role="tab"
 className={`tab-trigger ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
 onClick={handleClick}
 disabled={disabled}
 aria-selected={isActive}
 aria-disabled={disabled}
 >
 {children}
 </button>
 )
}

function TabContent({ value, children }) {
 const activeTab = useTabStore((state) => state.activeTab)

 if (activeTab !== value) {
 return null
 }

 return (
 <div role="tabpanel" className="tab-content">
 {children}
 </div>
 )
}

// Compound yapiyi olusturuyoruz
Tabs.List = TabList
Tabs.Trigger = TabTrigger
Tabs.Content = TabContent

export default Tabs
export { useTabStore }

★ Tabs.List = TabList atamasıyla sub-component'leri parent'a bağlıyoruz. Bu sayede <Tabs.List> şeklinde kullanabiliyoruz

★ Tek bir import Tabs from './Tabs' ile tüm alt component'lere erişim sağlanıyor

★ Store'u da export ediyoruz ki dışarıdan ihtiyaç duyan component'ler de aktif tab bilgisine erişebilsin

★ Hiçbir yerde createContext, useContext veya Provider yok. Zustand her şeyi kendi hallediyor

Yazdığımız Tab component’ini kullanalım:

// App.js
import Tabs from './Tabs'

function App() {
 return (
 <div className="app">
 <h1>Kullanici Paneli</h1>

 <Tabs defaultTab="profil">
 <Tabs.List>
 <Tabs.Trigger value="profil">🧑 Profil</Tabs.Trigger>
 <Tabs.Trigger value="projeler">📁 Projeler</Tabs.Trigger>
 <Tabs.Trigger value="ayarlar">⚙️ Ayarlar</Tabs.Trigger>
 <Tabs.Trigger value="arsiv" disabled>🗄️ Arsiv</Tabs.Trigger>
 </Tabs.List>

 <Tabs.Content value="profil">
 <h2>Profil Bilgileri</h2>
 <p>Ad: Yasin Ates</p>
 <p>Rol: Frontend Developer</p>
 </Tabs.Content>

 <Tabs.Content value="projeler">
 <h2>Aktif Projeler</h2>
 <ul>
 <li>E-ticaret Platformu</li>
 <li>Admin Dashboard</li>
 <li>Mobile App</li>
 </ul>
 </Tabs.Content>

 <Tabs.Content value="ayarlar">
 <h2>Hesap Ayarlari</h2>
 <p>Bildirim tercihleri, tema secimi ve dil ayarlari burada yer alir.</p>
 </Tabs.Content>
 </Tabs>
 </div>
 )
}

export default App

★ defaultTab="profil" ile sayfa ilk açıldığında Profil sekmesi aktif olacak

★ Arşiv tab'ı disabled olarak işaretlenmiş, tıklanamaz durumda

★ Her tab'ın içeriği tamamen bağımsız, istediğimiz component'i koyabiliyoruz

★ Yeni bir tab eklemek istediğimizde tek yapmamız gereken bir Tabs.Trigger ve bir Tabs.Content eklemek

onChange Callback eklemek istersek;

Gerçek projelerde tab değiştiğinde bir şeyler yapmak isteyebiliriz. Mesela analytics event'i göndermek, URL'i güncellemek ya da veri çekmek gibi. Bunun için store'umuza onChange desteği ekleyelim:

// useTabStore.js (guncellenmiş hali)
import { create } from 'zustand'

const useTabStore = create((set, get) => ({
 activeTab: '',
 onChange: null,

 setOnChange: (callback) => set({ onChange: callback }),

 setActiveTab: (tab) => {
 const previousTab = get().activeTab
 const onChange = get().onChange

 if (previousTab !== tab) {
 set({ activeTab: tab })
 if (onChange) {
 onChange(tab, previousTab)
 }
 }
 },
}))

export default useTabStore

★ get() ile mevcut state'e erişiyoruz. Bu Zustand'ın güzel özelliklerinden biri: set ile güncelle, get ile oku

★ previousTab !== tab kontrolü ile aynı tab'a tekrar tıklandığında gereksiz callback çağrısını engelliyoruz

★ onChange(tab, previousTab) ile hem yeni hem eski tab bilgisini dışarıya veriyoruz

Tabs component'ini de güncelleyelim:

function Tabs({ defaultTab, onChange, children }) {
 const setActiveTab = useTabStore((state) => state.setActiveTab)
 const setOnChange = useTabStore((state) => state.setOnChange)

 useEffect(() => {
 if (defaultTab) {
 setActiveTab(defaultTab)
 }
 if (onChange) {
 setOnChange(onChange)
 }
 }, [])

 return (
 <div className="tabs-container">
 {children}
 </div>
 )
}

Kullanımı da şöyle olacak:

<Tabs
 defaultTab="profil"
 onChange={(newTab, prevTab) => {
 console.log(`${prevTab} -> ${newTab} gecisi yapildi`)
 analytics.track('tab_change', { from: prevTab, to: newTab })
 }}
>
 {/* ... */}
</Tabs>

⚠️ Dikkat Edilmesi Gereken Noktalar

Compound Component Pattern güçlü bir araç ama her araç gibi doğru yerde kullanılmalı.

Ne zaman kullanalım:

★ Birbiriyle ilişkili component grupları oluşturduğumuzda (Tabs, Accordion, Dropdown, Modal)

★ Component'in API'sini esnek ve okunabilir tutmak istediğimizde

★ Prop drilling'den kaçınmak istediğimizde

★ Kendi component library'mizi veya design system'ımızı geliştirdiğimizde

Ne zaman kullanmayalım:

★ Basit, tek seviyeli component'lerde. Bir butona compound pattern uygulamak overengineering olur

★ State paylaşımı sadece bir seviye derinlikte ise. Bu durumda basit prop geçmek yeterli

★ Component'ler arasında gerçek bir ilişki yoksa. Pattern'ı kullanmak için kullanmayalım

📬 Geri Bildirim

Makaleyi yazarken, kaynakları belirleme ve araştırma için kendi notlarımı, yazım denetimi ve ek araştırma için Claude Opus 4.6 modelini kullandım. Resimleri üretmek için ise Gemini 3 Pro Preview 2k (Nano Banana Pro) modelini kullandım.

Yazı ile ilgili tavsiye, öneri, eleştirileri dikkate alıyorum. İletişime geçmek isterseniz bana websitemdeki sosyal medya adreslerimden veya Linkedin üzerinden ulaşabilirsiniz.

Sevgiyle kalın, Yasin 🤗

📚 Makaleyi Yazarken Kullandığım Kaynaklar

  1. Zustand GitHub Repository — Zustand'ın resmi repository'si, API referansı ve kullanım örnekleri
  2. Zustand Documentation — Zustand resmi dokümantasyonu
  3. Compound Pattern - patterns.dev — Compound Component Pattern'ın detaylı açıklaması ve React örnekleri
  4. React Hooks: Compound Components - Kent C. Dodds — Kent C. Dodds'un Compound Component Pattern rehberi
  5. Compound Components In React - Smashing Magazine — Smashing Magazine'in kapsamlı Compound Component yazısı
  6. Zustand and React Context - TkDodo — Zustand ve Context API'ın birlikte kullanımı üzerine detaylı yazı
  7. React Context API Documentation — React resmi Context API dokümantasyonu