Building Scalable, Type-Safe Modals with Zustand and Shadcn UI in React

Managing multiple modals in large-scale React or Next.js applications can be challenging, especially when modals are triggered from various components, carry dynamic data, support forms, or require state updates. This article outlines a scalable, maintainable, and production-ready architecture for building type-safe modals using Zustand for global state management and Shadcn UI for accessible, customizable modal components. This approach ensures type safety, accessibility, and reusability.
Why This Architecture Works
Modal management often falls into two problematic patterns:
Local state-based: Modal state is tightly coupled to a specific component, leading to repetitive code and poor scalability.
Prop-drilled: Modal state is passed through multiple component layers, resulting in bloated and hard-to-maintain code.
These approaches struggle in large applications with many modals. By combining Zustand with Shadcn UI, you can create a decoupled, scalable modal system that offers:
Global control: Open or close modals from any part of the app.
Dynamic payloads: Pass context or data to modals easily.
Self-contained modals: Each modal handles its own UI and logic.
No prop drilling: Avoid passing props through multiple layers.
Type safety: Ensure reliability with TypeScript.
Accessibility: Leverage Shadcn’s Radix UI foundation for ARIA-compliant modals.
This architecture is ideal for enterprise applications where modals are triggered dynamically, require form handling, or need to integrate with APIs.
Prerequisites
Before diving in, ensure you have:
A React or Next.js project setup.
Familiarity with TypeScript for type safety.
Basic knowledge of Tailwind CSS for styling (optional).
Install the required packages:
npm install zustand @hookform/resolvers yup react-hook-form lucide-react
For Shadcn UI, follow the official setup guide here.
Step 1: Set Up a Typed Modal Store with Zustand
Zustand provides a simple, reactive global state management solution. Create a typed modal store to track which modal is open and any associated data (e.g., an entity ID).
// store/useModalStore.ts
import { create } from 'zustand';
// Define supported modal types
export type ModalName =
| 'deleteAdminModal'
| 'editAdminModal'
| 'createAdminModal'
| 'confirmLogoutModal'
| 'successModal'
| 'addFeeModal'
| 'editFeesModal'
| 'resetPasswordOTPModal';
// Modal state interface
interface ModalState {
activeModal: ModalName | null;
entityId: string | null;
openModal: (modal: ModalName, entityId?: string | null) => void;
closeModal: () => void;
}
// Create the Zustand store
const useModalStore = create<ModalState>((set) => ({
activeModal: null,
entityId: null,
openModal: (modal, entityId = null) => set({ activeModal: modal, entityId }),
closeModal: () => set({ activeModal: null, entityId: null }),
}));
export default useModalStore;
Explanation
ModalName: A TypeScript union type listing all supported modals, making it easy to add new modals later.
ModalState: Defines the store’s structure, including the currently active modal (activeModal), an optional entityId for passing data, and methods to open (openModal) and close (closeModal) modals.
create: Zustand’s function to initialize the store with an initial state and methods.
Type safety: TypeScript ensures that only valid ModalName values are used, reducing runtime errors.
This store allows any component to open or close modals globally while maintaining a clean, reactive state.
Step 2: Create a Central Modal Container
The ModalContainer component acts as the single entry point for rendering all modals. It listens to the Zustand store and conditionally renders the appropriate modal component based on the activeModal state. Using dynamic imports from Next.js optimises performance by lazy-loading modal components.
// components/modals/ModalContainer.tsx
import dynamic from 'next/dynamic';
import useModalStore from '@/hooks/store/useModalStore';
// Lazy-load modal components
const DeleteAdminModal = dynamic(() => import('./DeleteAdminModal'));
const CreateAdminModal = dynamic(() => import('./CreateAdminModal'));
const EditAdminModal = dynamic(() => import('./EditAdminModal'));
const ConfirmLogoutModal = dynamic(() => import('./ConfirmLogoutModal'));
const SuccessModal = dynamic(() => import('./SuccessModal'));
const AddFeeModal = dynamic(() => import('./AddFeeModal'));
const EditFeesModal = dynamic(() => import('./EditFeesModal'));
const ResetPasswordOTPModal = dynamic(() => import('./ResetPasswordOTPModal'));
const ModalContainer = () => {
const { activeModal } = useModalStore();
switch (activeModal) {
case 'deleteAdminModal':
return <DeleteAdminModal />;
case 'createAdminModal':
return <CreateAdminModal />;
case 'editAdminModal':
return <EditAdminModal />;
case 'confirmLogoutModal':
return <ConfirmLogoutModal />;
case 'successModal':
return <SuccessModal />;
case 'addFeeModal':
return <AddFeeModal />;
case 'editFeesModal':
return <EditFeesModal />;
case 'resetPasswordOTPModal':
return <ResetPasswordOTPModal />;
default:
return null;
}
};
export default ModalContainer;
Explanation
Dynamic Imports: Lazy-loading with next/dynamic reduces the initial bundle size by loading modal components only when needed.
Switch Statement: Maps the activeModal state to the corresponding modal component, ensuring only one modal renders at a time.
useModalStore: Retrieves the activeModal state to determine which modal to display.
Place the ModalContainer at the root of your app to ensure it’s always available:
// app/layout.tsx
import ModalContainer from '@/components/modals/ModalContainer';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ModalContainer />
</body>
</html>
);
}
This ensures modals can be triggered from any part of the app without additional configuration.
Step 3: Build Self-Contained Modal Components
Each modal component is responsible for its own UI, form logic, and API interactions. Below is an example of a DeleteAdminModal that uses Shadcn UI components, React Hook Form for form handling, and Yup for validation.
// components/modals/DeleteAdminModal.tsx
import { useForm, SubmitHandler } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import Textarea from '@/components/inputs/text-area';
import useModalStore from '@/hooks/store/useModalStore';
import useDeleteAdminStore from '@/hooks/store/useDeleteAdminStore';
import { apiDeleteAdmin } from '@/lib/services/adminServices';
// Form validation schema
const formSchema = Yup.object({
comment: Yup.string().required('Comment is required'),
});
export default function DeleteAdminModal() {
const [loading, setLoading] = useState(false);
const { closeModal } = useModalStore();
const userName = useDeleteAdminStore((state) => state.userName);
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: yupResolver(formSchema),
defaultValues: { comment: '' },
mode: 'onChange',
});
const onSubmit: SubmitHandler<{ comment: string }> = async (values) => {
setLoading(true);
try {
await apiDeleteAdmin({ userName, comment: values.comment });
closeModal();
} catch (err) {
console.error('Failed to delete admin:', err);
} finally {
setLoading(false);
}
};
return (
<Dialog open onOpenChange={closeModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-center">Account Deletion</DialogTitle>
</DialogHeader>
<div className="p-6">
<p className="text-sm text-gray-600 mt-4 mb-2">
Why are you deleting this account?
</p>
<form onSubmit={handleSubmit(onSubmit)}>
<Textarea
id="comment"
{...register('comment')}
error={errors.comment?.message}
placeholder="Enter your reason..."
/>
<div className="flex justify-center mt-6">
<Button type="submit" className="w-1/2" disabled={loading}>
{loading ? (
<Loader2 size={16} className="animate-spin mr-2" />
) : (
'Submit'
)}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
}
Explanation
Shadcn UI: Uses Dialog, DialogContent, and DialogHeader for an accessible modal structure, with Button for form submission.
Form Handling: Integrates React Hook Form with Yup for robust form validation, ensuring the comment field is required.
API Integration: Calls a mock apiDeleteAdmin service to handle the deletion logic (replace with your actual API).
State Management: Uses useModalStore to close the modal and useDeleteAdminStore (a separate Zustand store) to access contextual data like userName.
Loading State: Displays a spinner (Loader2 from lucide-react) during API calls to improve UX.
Accessibility: The Dialog component from Shadcn UI (built on Radix UI) ensures ARIA compliance and keyboard navigation.
Step 4: Trigger Modals from Anywhere
With the modal store in place, you can trigger modals from any component without prop drilling. For example:
// components/AdminList.tsx import useModalStore from '@/hooks/store/useModalStore'; import useDeleteAdminStore from '@/hooks/store/useDeleteAdminStore'; export default function AdminList() { const { openModal } = useModalStore(); const { setUserName } = useDeleteAdminStore(); const handleDeleteClick = (userName: string) => { setUserName(userName); openModal('deleteAdminModal', userName); }; return ( <div> <button onClick={() => handleDeleteClick('admin123')} className="text-red-600 hover:underline" > Delete Admin </button> </div> ); }
Explanation
openModal: Triggers the deleteAdminModal and passes an entityId (e.g., admin123) for context.
useDeleteAdminStore: Sets the userName in a separate store to provide modal-specific data.
No Prop Drilling: The modal store handles state globally, so you don’t need to pass props through multiple components.
You can extend useModalStore to pass additional context (e.g., custom payloads) by adding properties to the ModalState interface.
Conclusion
By combining Zustand’s lightweight state management with Shadcn UI’s accessible components, you can build scalable, type-safe modals for React or Next.js applications. This architecture eliminates common pain points like prop drilling, ensures type safety with TypeScript, and provides a maintainable structure for large codebases. Whether you’re building an enterprise dashboard, e-commerce platform, or small project, this approach streamlines modal management and enhances the user experience.
Subscribe to my newsletter
Read articles from Chukwudi Nweze directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Chukwudi Nweze
Chukwudi Nweze
A frontend developer with 5 years of experience. I simplify complex technical concepts through technical writing.