Global UI State: Drawers, Modals, and Notifications
Manage global UI state for cart drawers, search modals, mobile menus, and toast notifications. Build a centralized UI store with Zustand.
Your theme has many UI elements that need global coordination—cart drawer, mobile menu, search modal, and notifications. In this lesson, we’ll build a UI state store that manages all of them.
The UI State Challenge
Multiple components need to control and respond to UI state:
- Cart icon opens the cart drawer
- Menu button opens the mobile menu
- Search icon opens the search modal
- Add to cart shows a success notification
- Escape key closes whatever is open
- Only one overlay should be open at a time
The UI Store
Create a centralized store for all UI state:
import { create } from 'zustand';
/* * Global UI State Store * * Manages all overlay/modal state in one place. Key principles: * - Only one drawer OR modal can be open at a time * - Opening one closes the other (prevents stacking) * - Notifications can stack (multiple toasts) * - Escape key closes everything */
// Union types for type-safe drawer/modal namestype DrawerType = 'cart' | 'menu' | 'filter' | null;type ModalType = 'search' | 'quickView' | 'sizeGuide' | null;
interface Notification { id: string; // Unique ID for removal type: 'success' | 'error' | 'info'; message: string; duration?: number; // Auto-dismiss after N milliseconds}
interface UIState { // Drawers: slide-in panels (cart, mobile menu) // null = none open, otherwise the drawer name activeDrawer: DrawerType;
// Modals: centered overlays (search, quick view) activeModal: ModalType; modalData: Record<string, unknown> | null; // Pass data to modals
// Notifications: toast messages that can stack notifications: Notification[];
// Actions - Drawers openDrawer: (drawer: DrawerType) => void; closeDrawer: () => void; toggleDrawer: (drawer: DrawerType) => void;
// Actions - Modals openModal: (modal: ModalType, data?: Record<string, unknown>) => void; closeModal: () => void;
// Actions - Notifications addNotification: (notification: Omit<Notification, 'id'>) => void; removeNotification: (id: string) => void; clearNotifications: () => void;
// Helpers - check state without subscribing isDrawerOpen: (drawer: DrawerType) => boolean; isModalOpen: (modal: ModalType) => boolean; isAnyOverlayOpen: () => boolean; closeAll: () => void;}
export const useUI = create<UIState>((set, get) => ({ activeDrawer: null, activeModal: null, modalData: null, notifications: [],
// Opening a drawer closes any open modal (prevent overlap) openDrawer: (drawer) => { set({ activeDrawer: drawer, activeModal: null, modalData: null }); },
closeDrawer: () => { set({ activeDrawer: null }); },
// Toggle: if already open, close it; otherwise open it toggleDrawer: (drawer) => { const current = get().activeDrawer; if (current === drawer) { set({ activeDrawer: null }); } else { set({ activeDrawer: drawer, activeModal: null, modalData: null }); } },
// Opening a modal closes any open drawer openModal: (modal, data = null) => { set({ activeModal: modal, modalData: data, // Pass data like { productHandle: 'xyz' } activeDrawer: null, }); },
closeModal: () => { set({ activeModal: null, modalData: null }); },
// Add notification with auto-generated ID addNotification: (notification) => { // Generate unique ID for tracking/removal const id = `notification-${Date.now()}-${Math.random().toString(36).slice(2)}`; const newNotification: Notification = { ...notification, id, duration: notification.duration ?? 5000, // Default 5 seconds };
// Add to stack set((state) => ({ notifications: [...state.notifications, newNotification], }));
// Schedule auto-removal if (newNotification.duration && newNotification.duration > 0) { setTimeout(() => { get().removeNotification(id); }, newNotification.duration); } },
removeNotification: (id) => { set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id), })); },
clearNotifications: () => { set({ notifications: [] }); },
// Helper methods use get() to read state without causing re-renders isDrawerOpen: (drawer) => get().activeDrawer === drawer, isModalOpen: (modal) => get().activeModal === modal, isAnyOverlayOpen: () => get().activeDrawer !== null || get().activeModal !== null,
// Close everything - useful for Escape key handler closeAll: () => { set({ activeDrawer: null, activeModal: null, modalData: null, }); },}));Convenience Hooks
Create focused hooks for specific UI elements:
// src/stores/ui.ts (continued)
/** * Hook for cart drawer */export function useCartDrawer() { const isOpen = useUI((state) => state.activeDrawer === 'cart'); const open = useUI((state) => () => state.openDrawer('cart')); const close = useUI((state) => state.closeDrawer); const toggle = useUI((state) => () => state.toggleDrawer('cart'));
return { isOpen, open, close, toggle };}
/** * Hook for mobile menu */export function useMobileMenu() { const isOpen = useUI((state) => state.activeDrawer === 'menu'); const open = useUI((state) => () => state.openDrawer('menu')); const close = useUI((state) => state.closeDrawer); const toggle = useUI((state) => () => state.toggleDrawer('menu'));
return { isOpen, open, close, toggle };}
/** * Hook for search modal */export function useSearchModal() { const isOpen = useUI((state) => state.activeModal === 'search'); const open = useUI((state) => () => state.openModal('search')); const close = useUI((state) => state.closeModal);
return { isOpen, open, close };}
/** * Hook for notifications */export function useNotifications() { const notifications = useUI((state) => state.notifications); const add = useUI((state) => state.addNotification); const remove = useUI((state) => state.removeNotification); const clear = useUI((state) => state.clearNotifications);
return { notifications, add, remove, clear };}Using the UI Store
Header Component
import { useCartDrawer, useMobileMenu, useSearchModal } from '@/stores/ui';import { useCart } from '@/stores/cart';
export function Header() { const { toggle: toggleCart } = useCartDrawer(); const { toggle: toggleMenu } = useMobileMenu(); const { open: openSearch } = useSearchModal(); const itemCount = useCart((state) => state.cart?.itemCount ?? 0);
return ( <header className="header"> <button className="menu-button" onClick={toggleMenu} aria-label="Open menu"> <MenuIcon /> </button>
<a href="/" className="logo"> Store Name </a>
<div className="header-actions"> <button onClick={openSearch} aria-label="Open search"> <SearchIcon /> </button>
<button onClick={toggleCart} aria-label={`Cart with ${itemCount} items`}> <CartIcon /> {itemCount > 0 && <span className="count">{itemCount}</span>} </button> </div> </header> );}Mobile Menu Drawer
import { useMobileMenu } from '@/stores/ui';import { Drawer } from '@/components/ui';
export function MobileMenu() { const { isOpen, close } = useMobileMenu();
return ( <Drawer isOpen={isOpen} onClose={close} position="left" title="Menu"> <nav className="mobile-nav"> <a href="/collections/all" onClick={close}> Shop All </a> <a href="/collections/new" onClick={close}> New Arrivals </a> <a href="/pages/about" onClick={close}> About </a> <a href="/pages/contact" onClick={close}> Contact </a> </nav> </Drawer> );}Search Modal
import { useSearchModal } from '@/stores/ui';import { Modal } from '@/components/ui';import { SearchInput } from './SearchInput';import { SearchResults } from './SearchResults';
export function SearchModal() { const { isOpen, close } = useSearchModal();
return ( <Modal isOpen={isOpen} onClose={close} title="Search" size="large"> <SearchInput autoFocus /> <SearchResults /> </Modal> );}Notifications Container
import { useNotifications } from '@/stores/ui';import { Toast } from '../Toast';import styles from './Notifications.module.css';
export function Notifications() { const { notifications, remove } = useNotifications();
if (notifications.length === 0) return null;
return ( <div className={styles.container} role="region" aria-label="Notifications"> {notifications.map((notification) => ( <Toast key={notification.id} message={notification.message} type={notification.type} onDismiss={() => remove(notification.id)} /> ))} </div> );}.container { position: fixed; bottom: 1rem; right: 1rem; z-index: 1100; display: flex; flex-direction: column; gap: 0.5rem; max-width: 400px;}
@media (max-width: 640px) { .container { left: 1rem; right: 1rem; max-width: none; }}Keyboard Handling
Close overlays with Escape key:
import { useEffect } from 'react';import { useUI } from '@/stores/ui';
export function useEscapeKey() { const closeAll = useUI((state) => state.closeAll); const isAnyOpen = useUI((state) => state.isAnyOverlayOpen());
useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isAnyOpen) { closeAll(); } };
document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [closeAll, isAnyOpen]);}Use in your main app component:
import { useEscapeKey } from '@/hooks/useEscapeKey';import { CartDrawer } from './cart/CartDrawer';import { MobileMenu } from './header/MobileMenu';import { SearchModal } from './search/SearchModal';import { Notifications } from './ui/Notifications';
export function App() { useEscapeKey();
return ( <> <CartDrawer /> <MobileMenu /> <SearchModal /> <Notifications /> </> );}Body Scroll Lock
Prevent background scrolling when overlays are open:
import { useEffect } from 'react';import { useUI } from '@/stores/ui';
export function useBodyScrollLock() { const isAnyOpen = useUI((state) => state.isAnyOverlayOpen());
useEffect(() => { if (isAnyOpen) { // Save current scroll position const scrollY = window.scrollY;
document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.width = '100%'; } else { // Restore scroll position const scrollY = document.body.style.top;
document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1); } }, [isAnyOpen]);}Integration with Cart Store
Show notification when adding to cart:
import { useUI } from './ui';
export const useCart = create<CartState>((set, get) => ({ // ... other state
addItem: async (variantId, quantity) => { set({ isLoading: true, error: null });
try { const cart = await cartApi.addToCart(variantId, quantity); set({ cart: transformShopifyCart(cart) });
// Show success notification useUI.getState().addNotification({ type: 'success', message: 'Added to cart!', duration: 3000, });
// Open cart drawer useUI.getState().openDrawer('cart'); } catch (error) { set({ error: 'Failed to add item' });
// Show error notification useUI.getState().addNotification({ type: 'error', message: 'Failed to add item to cart', }); } finally { set({ isLoading: false }); } },}));Quick View Modal Example
Modals can pass data:
import { useUI } from '@/stores/ui';
interface QuickViewButtonProps { productHandle: string;}
export function QuickViewButton({ productHandle }: QuickViewButtonProps) { const openModal = useUI((state) => state.openModal);
return ( <button onClick={() => openModal('quickView', { handle: productHandle })}>Quick View</button> );}import { useUI } from '@/stores/ui';import { Modal } from '@/components/ui';
export function QuickViewModal() { const isOpen = useUI((state) => state.activeModal === 'quickView'); const modalData = useUI((state) => state.modalData); const closeModal = useUI((state) => state.closeModal);
const productHandle = modalData?.handle as string | undefined;
if (!isOpen || !productHandle) return null;
return ( <Modal isOpen={isOpen} onClose={closeModal} title="Quick View" size="large"> <ProductQuickView handle={productHandle} /> </Modal> );}Key Takeaways
- Centralized UI store: One place for all overlay and notification state
- One overlay at a time: Opening one closes others automatically
- Convenience hooks: Create focused hooks for each UI element
- Keyboard handling: Global Escape key closes any overlay
- Body scroll lock: Prevent background scrolling when overlays are open
- Notifications with auto-dismiss: Stack notifications with configurable duration
- Modal data passing: Pass arbitrary data when opening modals
- Integration with other stores: Trigger UI changes from cart actions
Your state management foundation is complete! In the next module, we’ll build the Header and Navigation components.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...