Photo by Glenn Carstens-Peters on Unsplash
Dynamic Load Dialog Component with a Reusable Trigger and Loading Tricks in NextJS
Recently, I got a task to do a wizard form dialog component that is being used in multiple places in a NextJS project - There are few ways we could approach this. However, I find this trick really fun to be done in a way.
To use this trick I installed zustand
for global state management - can be used with other global state management libraries as well.
I know the importance of the First Time Load in SEO sense, so usually we will have to dynamic load the Dialog component where it is not visible to the first page load hence reducing the Dialog component from being downloaded. In NextJS this can be dynamic loaded by using next/dynamic
built-in from NextJS.
Getting Started
For a starter, we will use NextJS with ShadcnUI.
First, we make our Dialog component that uses ShadcnUI Dialog component and the inside can be anything for now - usually Wizard components has quite few files inside it for different forms for each steps - to make it succinct we are skipping that part.
// src/app/wizard-dialog/dialog.tsx
import { Dialog, DialogContent } from '@/components/ui/dialog';
export const WizardDialog = ({
open,
setOpen
}: {
open: boolean;
setOpen: (by: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
{/* Pretty Big Content that needs to be dynamic loaded */}
</DialogContent>
</Dialog>
)
}
After having the dialog component, we need to setup our global state management store for storing the state of the Dialog, like its open state and loading state - this can be custom to our needs - for now we will go with the basic setting.
// src/app/wizard-dialog/store.ts
import { create } from 'zustand'
interface WizardDialogState {
open: boolean
setOpen: (by: boolean) => void
isLoading: boolean
setIsLoading: (by: boolean) => void
}
export const useWizardDialog = create<WizardDialogState>()((set) => ({
open: false,
setOpen: (by) => set(() => ({ open: by })),
isLoading: false,
setIsLoading: (by) => set(() => ({ isLoading: by })),
}))
Having the store and the dialog component now, we can create our dynamic loaded functionality we will call it consumer.tsx
- which we will put it in the app/layout.tsx
for global usage.
// src/app/wizard-dialog/consumer.tsx
'use client'
import { useWizardDialog } from '@/app/wizard-dialog/store'
import dynamic from 'next/dynamic'
import { useEffect } from 'react'
const WizardDialogLoading = () => {
const setIsLoading = useWizardDialog((s) => s.setIsLoading)
useEffect(() => {
setIsLoading(true)
return () => {
setIsLoading(false)
}
}, [])
return null
}
const WizardDialog = dynamic(
() => import('@/app/wizard-dialog/dialog').then((mod) => mod.WizardDialog),
{
ssr: false,
loading: WizardDialogLoading,
}
)
export const WizardDialogConsumer = () => {
const open = useWizardDialog((s) => s.open)
const setOpen = useWizardDialog((s) => s.setOpen)
return open && <WizardDialog open={open} setOpen={setOpen} />
}
A bit explanation here, we use the open state from the store to indicate that when it is true, it will start the dynamic load the <WizardDialog />
component.
After creating a the consumer here we can put it inside the app/layout.tsx
// src/app/layout.tsx
import { Inter } from 'next/font/google'
import { WizardDialogConsumer } from '@/app/wizard-dialog/consumer'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body className={inter.className}>
<WizardDialogConsumer />
{children}
</body>
</html>
)
}
WizardDialogLoading
is pretty interesting trick here to manipulate our global state for our loading state. When the component is being first loaded, it will set loading to true and when it is mounted it will make our loading state to be false - with this logic, we can use globally the loading state later in our trigger to render any global loading component.
Below is the code for our trigger.tsx
// src/app/wizard-dialog/trigger.tsx
'use client'
import { useWizardDialog } from '@/app/wizard-dialog/store'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import React from 'react'
export const WizardDialogTrigger = ({
children,
}: {
children: React.ReactElement
}) => {
const setOpen = useWizardDialog((s) => s.setOpen)
const isLoading = useWizardDialog((s) => s.isLoading)
const onClick = () => {
setOpen(true)
}
return (
<>
{children &&
React.cloneElement(children, {
onClick,
className: cn(
children?.props?.className,
isLoading && 'flex flex-items-center justify-center'
),
children: isLoading ? (
<Loader2 className='animate-spin text-current' />
) : (
children?.props?.children
),
})}
</>
)
}
A bit explanation here, we are using React.cloneElement
to our children component to modify its onClick
function - so that we can globally let whichever component children of this trigger would be clickable and changing the state to be true.
And we can use the trigger like so - just wrap any clickable component with our custom trigger component.
// src/app/page.tsx
import { WizardDialogTrigger } from '@/app/wizard-dialog/trigger'
export default function Page() {
return (
<div>
<WizardDialogTrigger>
<button>Take Survey</button>
</WizardDialogTrigger>
</div>
)
}
Learnings
We have done making our Dialog component dynamically loaded into the our NextJS app to ensure it will not block the first page timing of our page.
This is only one way of doing it - hope it can be a foundation of what next to be implemented in the future enhancements.
Further enhancement can be like dividing the loading state into multiple keys states instead of one global loading state. React.cloneElement
is easy to get pitfall from the React documentation - we can use alternative like pass the children into a prop.
I have uploaded a sample repo into GitHub as well incase needed.
https://github.com/linkb15/blog-dynamic-load-dialog
Cheers!