Refactoring a React Modal into a Proper Dialog Component with TypeScript

Custom modals are common in React applications. They often start with a boolean state, an overlay <div>, and some CSS controlling whether the modal is visible.

As an application grows, however, a design system may require a proper Dialog structure:

<Dialog>
    <DialogTrigger asChild>
        <Button>Open Dialog</Button>
    </DialogTrigger>

    <DialogContent>
        <DialogHeader>
            <DialogTitle>Dialog title</DialogTitle>
            <DialogDescription>
                Dialog description
            </DialogDescription>
        </DialogHeader>

        <DialogFooter>
            <Button variant="secondary">Cancel</Button>
            <Button variant="primary">Submit</Button>
        </DialogFooter>
    </DialogContent>
</Dialog>

Migrating to this structure becomes slightly more complicated when the trigger, state management, and dialog content are split across several React components.

This article explains a clean and type-safe approach.

The Original Architecture

Imagine a feedback feature with two icons:

<div id="feedback-icons">
    <img
        src="/like.svg"
        alt="Positive feedback"
        onClick={() => setIsLikeOpen(true)}
    />

    <img
        src="/dislike.svg"
        alt="Negative feedback"
        onClick={() => setIsDislikeOpen(true)}
    />
</div>

The application has three components:

  1. Trigger component – contains the Like and Dislike buttons.
  2. Parent component – manages the state.
  3. Prompt component – displays the actual modal and sends the feedback.

Originally, the parent might manage two states:

const [isLikeOpen, setIsLikeOpen] = useState(false);
const [isDislikeOpen, setIsDislikeOpen] = useState(false);

The modal itself might then use:

if (!isOpen) return null;

followed by custom HTML:

<div className="modal-overlay">
    <div className="modal-content">
        ...
    </div>
</div>

It works, but we can simplify the architecture while adopting the design system’s Dialog component.

Use a Controlled Dialog

Because the trigger and dialog content exist in separate components, a controlled Dialog is a good solution.

Instead of two boolean states, we can represent the current feedback type with one state:

type FeedbackType = "like" | "dislike";

const [dialogType, setDialogType] =
    useState<FeedbackType | null>(null);

Now:

  • null means no dialog is open.
  • "like" means the positive-feedback dialog is open.
  • "dislike" means the negative-feedback dialog is open.

This prevents both dialogs from accidentally being open at the same time.

Component 1: Feedback Triggers

The trigger component only needs to tell the parent what the user selected:

interface FeedbackTriggersProps {
    onTrigger: (type: "like" | "dislike") => void;
}

const FeedbackTriggers = ({
    onTrigger
}: FeedbackTriggersProps) => (
    <div id="feedback-icons">
        <img
            src="/like.svg"
            alt="Positive feedback"
            onClick={() => onTrigger("like")}
        />

        <img
            src="/dislike.svg"
            alt="Negative feedback"
            onClick={() => onTrigger("dislike")}
        />
    </div>
);

The component no longer needs to know how the dialog works.

Component 2: Parent State

The parent becomes responsible for deciding which dialog is visible:

const [dialogType, setDialogType] =
    useState<"like" | "dislike" | null>(null);

return (
    <>
        <FeedbackTriggers onTrigger={setDialogType} />

        <PromptDialog
            type={dialogType}
            isOpen={dialogType !== null}
            onOpenChange={(isOpen) => {
                if (!isOpen) {
                    setDialogType(null);
                }
            }}
            originalContent={originalContent}
        />
    </>
);

The important property here is:

isOpen={dialogType !== null}

This gives the Dialog component an actual boolean value.

Component 3: The Dialog

The custom modal can now be replaced with the design system’s Dialog:

interface PromptDialogProps {
    isOpen: boolean;
    onOpenChange: (isOpen: boolean) => void;
    type: "like" | "dislike" | null;
    originalContent: unknown;
}

const PromptDialog = ({
    isOpen,
    onOpenChange,
    type,
    originalContent
}: PromptDialogProps) => {

    const [inputValue, setInputValue] = useState("");
    const [isSent, setIsSent] = useState(false);

    return (
        <Dialog
            open={isOpen}
            onOpenChange={onOpenChange}
        >
            <DialogContent>
                <DialogHeader>
                    <DialogTitle>
                        {type === "like"
                            ? "What was helpful about this response?"
                            : "What was the issue with this response?"}
                    </DialogTitle>

                    <DialogDescription>
                        Your feedback helps improve the responses.
                    </DialogDescription>
                </DialogHeader>

                <textarea
                    value={inputValue}
                    onChange={(e) =>
                        setInputValue(e.target.value)
                    }
                    placeholder="Enter your feedback..."
                />

                <DialogFooter>
                    <Button
                        variant="secondary"
                        onClick={() => onOpenChange(false)}
                    >
                        Cancel
                    </Button>

                    <Button
                        variant="primary"
                        disabled={!inputValue.trim()}
                    >
                        Submit feedback
                    </Button>
                </DialogFooter>
            </DialogContent>
        </Dialog>
    );
};

The custom modal-overlay and modal-content structure is no longer responsible for dialog behavior.

The TypeScript open Error

A particularly confusing error can appear during this refactoring:

Type '(url?: string | URL | undefined,
target?: string | undefined,
features?: string | undefined) => Window | null'
is not assignable to type 'boolean | undefined'.

It usually appears here:

<Dialog open={open}>

The Dialog expects:

open?: boolean;

So why is TypeScript seeing a function?

Browsers already expose a global function:

window.open()

Its TypeScript signature resembles the function shown in the error message.

If your component does not have a properly declared boolean variable called open, TypeScript may resolve open to the browser’s global function.

A simple solution is to use a clearer name:

<Dialog
    open={isOpen}
    onOpenChange={onOpenChange}
>

and explicitly type it:

interface DialogProps {
    isOpen: boolean;
    onOpenChange: (isOpen: boolean) => void;
}

Names such as isOpen, isLoading, isDisabled, and isSubmitting also make it immediately obvious that the value is a boolean.

Watch Out for Conditional React Hooks

There is another issue commonly found in custom modal implementations:

const Prompt = ({ isOpen }) => {
    if (!isOpen) return null;

    const [inputValue, setInputValue] = useState("");
    const [isSent, setIsSent] = useState(false);
};

React Hooks should not be called conditionally.

At minimum, the hooks should come first:

const Prompt = ({ isOpen }) => {
    const [inputValue, setInputValue] = useState("");
    const [isSent, setIsSent] = useState(false);

    if (!isOpen) return null;
};

When using a controlled Dialog, however, the manual check usually becomes unnecessary:

<Dialog open={isOpen}>

The Dialog component handles its visibility.

Reset the Dialog After Closing

It is usually useful to clear previously entered feedback when the dialog closes:

useEffect(() => {
    if (!isOpen) {
        setInputValue("");
        setIsSent(false);
    }
}, [isOpen]);

This ensures that each new feedback session starts with a clean form.

Use Strong TypeScript Types

Avoid using any unnecessarily:

interface PromptProps {
    isOpen: boolean;
    onClose: any;
    status: any;
    originalContent: any;
}

A better definition is:

type FeedbackType = "like" | "dislike";

interface PromptProps {
    isOpen: boolean;
    onOpenChange: (isOpen: boolean) => void;
    status: FeedbackType;
    originalContent: unknown;
}

TypeScript can now catch invalid feedback types and incorrect callbacks during development.

Why Use a Proper Dialog?

A custom <div> modal may look simpler, but a mature Dialog implementation usually provides important functionality automatically:

  • keyboard navigation;
  • Escape-to-close behavior;
  • focus management;
  • focus restoration;
  • accessibility attributes;
  • screen-reader support;
  • overlay management;
  • consistent styling and behavior.

These features can be surprisingly difficult to implement correctly from scratch.

Final Architecture

The resulting application has a simple data flow:

Like / Dislike
      ↓
Trigger Component
      ↓
Parent State
      ↓
"like" | "dislike" | null
      ↓
Controlled Dialog
      ↓
Feedback Form
      ↓
Backend API

Each component has one clear responsibility.

The trigger determines what the user selected, the parent determines whether and which dialog is open, and the dialog handles presentation and feedback submission.

Conclusion

Refactoring a custom React modal into a proper Dialog component improves more than just the HTML structure. It can make the application easier to maintain, more accessible, and safer from a TypeScript perspective.

The key points are to use a controlled Dialog when the trigger and content are separated, represent mutually exclusive dialogs with a single state, prefer explicit boolean names such as isOpen, avoid conditional React Hooks, and use strong TypeScript types instead of any.

And if TypeScript suddenly tells you that open is a function returning Window | null, remember to check for the browser’s built-in window.open() before blaming the Dialog component.

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back