Dynamic Load Dialog Component with a Reusable Trigger and Loading Tricks in NextJS

·

5 min read

Featured on Hashnode

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!