Introduction to State Management in Shadcn UI
Shadcn UI has become one of the most popular component libraries in the React ecosystem, but unlike traditional component libraries, it doesn't ship as an npm package. Instead, Shadcn UI provides you with the raw source code of components that you copy directly into your project. This unique approach gives you complete control over styling, behavior, and โ crucially โ state management. Because Shadcn components are built on top of Radix UI primitives, they come with their own internal state for things like open/close toggles, selected values, and focus management. However, when you need to coordinate state across multiple components or share data with external systems, you'll need to implement your own state management strategy.
This tutorial explores the various patterns and libraries you can use to manage state effectively in a Shadcn UI-powered application. We'll cover local component state, lifted state, the Context API, and popular state management libraries like Zustand, Jotai, and Redux Toolkit. We'll also look at form state management with React Hook Form and server state with TanStack Query.
Why State Management Matters in Shadcn UI
State management is the backbone of any interactive React application. In a Shadcn UI project, you'll encounter several scenarios where proper state management is essential:
- Controlled vs uncontrolled components: Shadcn components like Dialog, Popover, and DropdownMenu can be controlled or uncontrolled. You need a strategy for managing their open/close state.
- Form handling: Shadcn provides form components, but you need a way to manage form values, validation, and submission.
- Shared UI state: Themes, sidebar open/close, command palette visibility โ these states often need to be accessed by multiple components.
- Server data synchronization: Fetching, caching, and updating data from APIs requires a robust server state strategy.
- Complex application state: Shopping carts, user preferences, multi-step wizards, and other complex flows need structured state management.
Without a clear state management strategy, your application can quickly become difficult to maintain, with prop drilling, inconsistent state, and hard-to-track bugs.
Understanding Shadcn UI's Component Architecture
Before diving into state management patterns, it's important to understand how Shadcn UI components handle their own state. Most interactive Shadcn components are built on Radix UI primitives, which support both controlled and uncontrolled patterns.
Uncontrolled Components
In uncontrolled mode, the component manages its own state internally. You don't need to pass any state-related props:
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function UncontrolledDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Hello World</DialogTitle>
</DialogHeader>
<p>This dialog manages its own open state.</p>
</DialogContent>
</Dialog>
)
}
Controlled Components
In controlled mode, you manage the state externally and pass it to the component via props. This is useful when you need to programmatically control the component or synchronize it with other state:
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function ControlledDialog() {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Open Dialog</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Controlled Dialog</DialogTitle>
</DialogHeader>
<p>This dialog's state is controlled externally.</p>
<Button onClick={() => setOpen(false)}>Close</Button>
</DialogContent>
</Dialog>
</>
)
}
Understanding this duality is key to choosing the right state management approach for each scenario.
Local State Management with useState
The simplest form of state management in a Shadcn UI application is React's built-in useState hook. This is appropriate for state that is confined to a single component or a small component tree.
Managing a Select Component
import { useState } from "react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function LanguageSelector() {
const [language, setLanguage] = useState("en")
return (
<div className="space-y-2">
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Selected: {language}
</p>
</div>
)
}
Managing Tabs
import { useState } from "react"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function SettingsTabs() {
const [activeTab, setActiveTab] = useState("account")
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-[400px]">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger>
<TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
</CardHeader>
<CardContent>
<p>Manage your account settings here.</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
</CardHeader>
<CardContent>
<p>Change your password here.</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="appearance">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent>
<p>Customize the appearance of the app.</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}
Local state is perfect when the state doesn't need to be shared with other parts of the application. However, as your application grows, you'll need more sophisticated approaches.
Lifted State Pattern
When multiple components need access to the same state, the simplest approach is to lift the state up to their nearest common ancestor. This is a fundamental React pattern that works well with Shadcn UI components.
Example: Coordinated Form and Preview
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function ProfileEditor() {
const [profile, setProfile] = useState({
name: "",
bio: "",
email: "",
})
const handleChange = (field: string, value: string) => {
setProfile((prev) => ({ ...prev, [field]: value }))
}
const handleSave = () => {
console.log("Saving profile:", profile)
}
return (
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Edit Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={profile.name}
onChange={(e) => handleChange("name", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={profile.email}
onChange={(e) => handleChange("email", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
value={profile.bio}
onChange={(e) => handleChange("bio", e.target.value)}
/>
</div>
<Button onClick={handleSave}>Save Changes</Button>
</CardContent>
</Card>
<ProfilePreview profile={profile} />
</div>
)
}
function ProfilePreview({ profile }: { profile: { name: string; bio: string; email: string } }) {
return (
<Card>
<CardHeader>
<CardTitle>Live Preview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<h3 className="text-lg font-semibold">
{profile.name || "Your Name"}
</h3>
<p className="text-sm text-muted-foreground">
{profile.email || "your@email.com"}
</p>
</div>
<p className="text-sm">
{profile.bio || "Your bio will appear here."}
</p>
</div>
</CardContent>
</Card>
)
}
While lifting state works well for small component trees, it can lead to prop drilling when the state needs to be passed through many layers of components.
Using the Context API with Shadcn UI
The React Context API provides a way to share state across components without prop drilling. This is particularly useful for global UI state like themes, sidebar visibility, or command palette state.
Creating a Theme Context
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from "react"
type Theme = "light" | "dark" | "system"
type ThemeContextValue = {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: "light" | "dark"
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as Theme) || "system"
}
return "system"
})
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light")
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
setResolvedTheme(systemTheme)
} else {
root.classList.add(theme)
setResolvedTheme(theme)
}
localStorage.setItem("theme", theme)
}, [theme])
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}
Using the Theme Context with a Shadcn DropdownMenu
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/contexts/theme-context"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Creating a Command Palette Context
Command palettes are a common pattern in modern applications. Here's how to manage command palette state using Context:
import {
createContext,
useContext,
useState,
ReactNode,
} from "react"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
type CommandPaletteContextValue = {
open: boolean
setOpen: (open: boolean) => void
toggle: () => void
}
const CommandPaletteContext = createContext<
CommandPaletteContextValue | undefined
>(undefined)
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
const toggle = () => setOpen((prev) => !prev)
return (
<CommandPaletteContext.Provider value={{ open, setOpen, toggle }}>
{children}
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
<CommandItem>Calculator</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</CommandPaletteContext.Provider>
)
}
export function useCommandPalette() {
const context = useContext(CommandPaletteContext)
if (!context) {
throw new Error("useCommandPalette must be used within a CommandPaletteProvider")
}
return context
}
You can then trigger the command palette from anywhere in your application:
import { useEffect } from "react"
import { useCommandPalette } from "@/contexts/command-palette-context"
import { Button } from "@/components/ui/button"
export function Navbar() {
const { toggle, setOpen } = useCommandPalette()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
toggle()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggle])
return (
<nav className="flex items-center justify-between p-4 border-b">
<h1 className="text-lg font-bold">My App</h1>
<Button variant="outline" onClick={() => setOpen(true)}>
Search (โK)
</Button>
</nav>
)
}
Zustand Integration with Shadcn UI
Zustand is a lightweight, fast, and scalable state management library that works exceptionally well with Shadcn UI. It provides a simple API with minimal boilerplate and doesn't require providers.
Installing Zustand
npm install zustand
Creating a Cart Store
import { create } from "zustand"
import { persist } from "zustand/middleware"
type CartItem = {
id: string
name: string
price: number
quantity: number
image: string
}
type CartStore = {
items: CartItem[]
isOpen: boolean
addItem: (item: Omit<CartItem, "quantity">) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
openCart: () => void
closeCart: () => void
toggleCart: () => void
getTotalItems: () => number
getTotalPrice: () => number
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
getTotalItems: () =>
get().items.reduce((total, item) => total + item.quantity, 0),
getTotalPrice: () =>
get().items.reduce(
(total, item) => total + item.price * item.quantity,
0
),
}),
{
name: "cart-storage",
partialize: (state) => ({ items: state.items }),
}
)
)
Building a Shopping Cart with Shadcn Sheet
import { Minus, Plus, ShoppingBag, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from "@/components/ui/sheet"
import { Separator } from "@/components/ui/separator"
import { useCartStore } from "@/stores/cart-store"
import { formatCurrency } from "@/lib/utils"
export function CartSheet() {
const {
items,
isOpen,
closeCart,
removeItem,
updateQuantity,
getTotalPrice,
clearCart,
} = useCartStore()
const total = getTotalPrice()
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
<SheetContent className="flex flex-col w-full sm:max-w-lg">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<ShoppingBag className="h-5 w-5" />
Shopping Cart ({items.length})
</SheetTitle>
</SheetHeader>
{items.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">Your cart is empty</p>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto space-y-4 py-4">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
<img
src={item.image}
alt={item.name}
className="h-16 w-16 rounded-md object-cover"
/>
<div className="flex-1 space-y-1">
<h4 className="text-sm font-medium">{item.name}</h4>
<p className="text-sm text-muted-foreground">
{formatCurrency(item.price)}
</p>
<div className="flex items-center gap-2">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() =>
updateQuantity(item.id, item.quantity - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<span className="text-sm w-8 text-center">
{item.quantity}
</span>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() =>
updateQuantity(item.id, item.quantity + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 ml-auto"
onClick={() => removeItem(item.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
<Separator />
<SheetFooter>
<div className="flex justify-between w-full mb-4">
<span className="font-medium">Total</span>
<span className="font-bold">{formatCurrency(total)}</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={clearCart} className="flex-1">
Clear Cart
</Button>
<Button className="flex-1">Checkout</Button>
</div>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
)
}
Adding Items to the Cart
import { ShoppingCart } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { useCartStore } from "@/stores/cart-store"
type Product = {
id: string
name: string
price: number
image: string
category: string
}
export function ProductCard({ product }: { product: Product }) {
const { addItem, openCart } = useCartStore()
const handleAddToCart = () => {
addItem(product)
openCart()
}
return (
<Card className="overflow-hidden">
<img
src={product.image}
alt={product.name}
className="h-48 w-full object-cover"
/>
<CardHeader>
<Badge variant="secondary" className="w-fit">
{product.category}
</Badge>
<CardTitle>{product.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
${product.price.toFixed(2)}
</p>
</CardContent>
<CardFooter>
<Button onClick={handleAddToCart} className="w-full">
<ShoppingCart className="mr-2 h-4 w-4" />
Add to Cart
</Button>
</CardFooter>
</Card>
)
}
Jotai Integration with Shadcn UI
Jotai is an atomic state management library that takes a bottom-up approach. Instead of storing state in a single store, you create individual atoms of state that can be composed together. This works particularly well for fine-grained state updates.
Installing Jotai
npm install jotai
Creating Atoms for a Multi-Step Wizard
import { atom, useAtom, useAtomValue } from "jotai"
// Step state
const currentStepAtom = atom(0)
// Form data atoms
const personalInfoAtom = atom({
firstName: "",
lastName: "",
email: "",
})
const addressInfoAtom = atom({
street: "",
city: "",
state: "",
zipCode: "",
})
const preferencesAtom = atom({
newsletter: false,
notifications: true,
theme: "light" as "light" | "dark",
})
// Derived atom for total steps
const totalStepsAtom = atom(3)
// Derived atom for progress percentage
const progressAtom = atom((get) => {
const current = get(currentStepAtom)
const total = get(totalStepsAtom)
return ((current + 1) / total) * 100
})
// Derived atom for all form data
const allFormDataAtom = atom((get) => ({
personal: get(personalInfoAtom),
address: get(addressInfoAtom),
preferences: get(preferencesAtom),
}))
export {
currentStepAtom,
personalInfoAtom,
addressInfoAtom,
preferencesAtom,
totalStepsAtom,
progressAtom,
allFormDataAtom,
}
Building the Wizard with Shadcn Components
import { Check } from "lucide-react"
import { useAtom, useAtomValue } from "jotai"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Progress } from "@/components/ui/progress"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
currentStepAtom,
personalInfoAtom,
addressInfoAtom,
preferencesAtom,
progressAtom,
allFormDataAtom,
} from "@/atoms/wizard-atoms"
const steps = ["Personal", "Address", "Preferences"]
export function MultiStepWizard() {
const [currentStep, setCurrentStep] = useAtom(currentStepAtom)
const progress = useAtomValue(progressAtom)
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
}
}
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSubmit = () => {
const data = useAtomValue(allFormDataAtom)
console.log("Form submitted:", data)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="space-y-2">
<div className="flex justify-between">
{steps.map((step, index) => (
<div key={step} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full border-2 ${
index <= currentStep
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/30 text-muted-foreground"
}`}
>
{index < currentStep ? (
<Check className="h-4 w-4" />
) : (
index + 1
)}
</div>
<span className="text-sm font-medium hidden sm:block">
{step}
</span>
</div>
))}
</div>
<Progress value={progress} />
</div>
<Card>
<CardHeader>
<CardTitle>{steps[currentStep]} Information</CardTitle>
</CardHeader>
<CardContent>
{currentStep === 0 && <PersonalInfoStep />}
{currentStep === 1 && <AddressInfoStep />}
{currentStep === 2 && <PreferencesStep />}
</CardContent>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 0}
>
Back
</Button>
{currentStep < steps.length - 1 ? (
<Button onClick={handleNext}>Next</Button>
) : (
<Button onClick={handleSubmit}>Submit</Button>
)}
</div>
</div>
)
}
function PersonalInfoStep() {
const [info, setInfo] = useAtom(personalInfoAtom)
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
value={info.firstName}
onChange={(e) =>
setInfo({ ...info, firstName: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
value={info.lastName}
onChange={(e) =>
setInfo({ ...info, lastName: e.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={info.email}
onChange