State Management and Shopify APIs Intermediate 10 min read

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:

src/stores/ui.ts
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 names
type 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

src/components/header/Header.tsx
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

src/components/header/MobileMenu.tsx
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

src/components/search/SearchModal.tsx
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

src/components/ui/Notifications/Notifications.tsx
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>
);
}
src/components/ui/Notifications/Notifications.module.css
.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:

src/hooks/useEscapeKey.ts
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:

src/components/App.tsx
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:

src/hooks/useBodyScrollLock.ts
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:

src/stores/cart.ts
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:

src/components/product/QuickViewButton.tsx
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>
);
}
src/components/product/QuickViewModal.tsx
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

  1. Centralized UI store: One place for all overlay and notification state
  2. One overlay at a time: Opening one closes others automatically
  3. Convenience hooks: Create focused hooks for each UI element
  4. Keyboard handling: Global Escape key closes any overlay
  5. Body scroll lock: Prevent background scrolling when overlays are open
  6. Notifications with auto-dismiss: Stack notifications with configurable duration
  7. Modal data passing: Pass arbitrary data when opening modals
  8. 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...