DEV Community

SeongKuk Han
SeongKuk Han

Posted on

React TS: How I manage modal components (Custom Modal Hook)

How I manage modal components (Custom Modal Hook)

Let's say there is a modal that has title and content input fields. You can implement the modal like below.

I used MUI for UI.

import { useForm } from 'react-hook-form';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';

import { IModal } from '../../../types/modal';

export interface PostUploadModalProps extends IModal {
  onSubmit?: (title: string, content: string) => void;
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const PostUploadModal = ({
  visible = false,
  onClose,
  onSubmit,
}: PostUploadModalProps) => {
  const { register, handleSubmit: handleFormSubmit } = useForm<{
    title: string;
    content: string;
  }>();

  const handleSubmit: Parameters<typeof handleFormSubmit>[0] = (values) => {
    onSubmit?.(values.title, values.content);
    onClose?.();
  };

  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        <TextField
          {...register('title', { required: true })}
          sx={{ width: '100%', marginBottom: 2 }}
          label="Title"
          placeholder="Enter the title"
        />
        <TextField
          {...register('content', { required: true })}
          sx={{ width: '100%', marginBottom: 2 }}
          label="Content"
          multiline
          rows={4}
          placeholder="Enter the content"
        />
        <Grid container justifyContent="flex-end">
          <Button
            variant="contained"
            color="success"
            onClick={handleFormSubmit(handleSubmit)}
          >
            Submit
          </Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default PostUploadModal;

Enter fullscreen mode Exit fullscreen mode

And you can use this modal.

import { useState } from 'react';
import PostUploadModal, {
  PostUploadModalProps,
} from './components/modals/PostUploadModal';

function App() {
  const [postUploadModalProps, setPostuploadModalsProps] = useState<
    PostUploadModalProps | undefined
  >();

  const openPostUploadModal = () => {
    setPostuploadModalsProps({
      onClose: () => setPostuploadModalsProps(undefined),
      visible: true,
      onSubmit: (title, content) => console.log(title, content),
    });
  };

  return (
    <div>
      {postUploadModalProps && <PostUploadModal {...postUploadModalProps} />}
      <button onClick={openPostUploadModal}>Open PostUploadModal</button>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

If you call in this way, you have to define the state for the modal every where you need the modal.

I'm going to share a strategy that I use in my projects. Before I start it, you should be aware of that it would be implemented a bit different depending on projects.

I'm going to show you 5 different modals.

  • Alert
  • Confirm
  • A Modal that gets no props
  • Input Form
  • A modal that calls an API

ModalContext

Before making modals, you need to implement the base using React.Context.

[hooks/useModal.tsx]

import React, { createContext, useCallback, useContext, useState } from 'react';

interface IModalContext {}

const ModalContext = createContext<IModalContext>({} as IModalContext);

const useDefaultModalLogic = <T extends unknown>() => {
  const [visible, setVisible] = useState(false);
  const [props, setProps] = useState<T | undefined>();

  const openModal = useCallback((props?: T) => {
    setProps(props);
    setVisible(true);
  }, []);

  const closeModal = useCallback(() => {
    setProps(undefined);
    setVisible(false);
  }, []);

  return {
    visible,
    props,
    openModal,
    closeModal,
  };
};

export const useModal = () => useContext(ModalContext);

export const ModalContextProvider = ({
  children,
}: {
  children?: React.ReactNode;
}) => {
  const modalContextValue: IModalContext = {};

  return (
    <ModalContext.Provider value={modalContextValue}>
      {children}
    </ModalContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

[types/modal.ts]

export interface IModal {
  onClose?: VoidFunction;
  visible?: boolean;
}

export type OpenModal<T> = (params: T) => void;
Enter fullscreen mode Exit fullscreen mode

[App.tsx]

import Examples from './components/Examples';
import { ModalContextProvider } from './hooks/useModal';

function App() {
  return (
    <ModalContextProvider>
      <Examples />
    </ModalContextProvider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode
  • IModalContext: the type that the context has
  • ModalContext: React Context
  • useDefaultModalLogic: Provides variables and functions that need for showing and hiding a modal
  • useModal: Modal Hook
  • ModalContextProvider: A provider, it will be in App.tsx.
  • IModal: Default Interface that modals have
  • OpenModal: Open Modal Function Type

Alert

[components/modals/Alert/index.tsx]

import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';

import { IModal } from '../../../types/modal';

export interface AlertProps extends IModal {
  title?: string;
  message?: string;
  onOk?: VoidFunction;
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const Alert = ({
  visible = false,
  onClose,
  title,
  message,
  onOk,
}: AlertProps) => {
  const handleOk = () => {
    onOk?.();
    onClose?.();
  };
  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        {title && (
          <Typography variant="h6" component="h2">
            {title}
          </Typography>
        )}
        {message && <Typography sx={{ mt: 2 }}>{message}</Typography>}
        <Grid container justifyContent="flex-end">
          <Button onClick={handleOk}>OK</Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default Alert;
Enter fullscreen mode Exit fullscreen mode

[hooks/useModal.tsx]

...
interface IModalContext {
  openAlert: OpenModal<AlertProps>;
}
...
export const ModalContextProvider = ({
  children,
}: {
  children?: React.ReactNode;
}) => {
  const {
    openModal: openAlert,
    closeModal: closeAlert,
    props: alertProps,
    visible: alertVisible,
  } = useDefaultModalLogic<AlertProps>();

  const modalContextValue: IModalContext = {
    openAlert,
  };

  return (
    <ModalContext.Provider value={modalContextValue}>
      {alertProps && (
        <Alert {...alertProps} onClose={closeAlert} visible={alertVisible} />
      )}
      {children}
    </ModalContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

[components/Examples]

import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';

import { useModal } from '../../hooks/useModal';

function Examples() {
  const { openAlert } = useModal();

  const openAlertExample = () => {
    openAlert({
      title: 'Alert Example',
      message: 'Hello Dev.to!',
    });
  };

  return (
    <Container maxWidth="sm" sx={{ textAlign: 'center', marginTop: 12 }}>
      <Grid container spacing={2} direction="column">
        <Grid item>
          <Button variant="contained" onClick={openAlertExample}>
            Alert
          </Button>
        </Grid>
      </Grid>
    </Container>
  );
}

export default Examples;
Enter fullscreen mode Exit fullscreen mode

You open Alert modal like calling a function.

openAlert({
  title: 'Alert Example',
  message: 'Hello Dev.to!',
});
Enter fullscreen mode Exit fullscreen mode

if you need, you can pass the onOk callback function

Alert Example


Confirm

[components/modals/Confirm/index.tsx]

import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';

import { IModal } from '../../../types/modal';

export interface ConfirmProps extends IModal {
  title?: string;
  message?: string;
  cancelText?: string;
  confirmText?: string;
  onCancel?: VoidFunction;
  onConfirm?: VoidFunction;
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const Confirm = ({
  visible = false,
  onClose,
  title,
  message,
  cancelText,
  onCancel,
  confirmText,
  onConfirm,
}: ConfirmProps) => {
  const handleCancel = () => {
    onCancel?.();
    onClose?.();
  };
  const handleConfirm = () => {
    onConfirm?.();
    onClose?.();
  };
  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        {title && (
          <Typography variant="h6" component="h2">
            {title}
          </Typography>
        )}
        {message && <Typography sx={{ mt: 2 }}>{message}</Typography>}
        <Grid container justifyContent="flex-end">
          <Button onClick={handleCancel}>{cancelText}</Button>
          <Button onClick={handleConfirm}>{confirmText}</Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default Confirm;
Enter fullscreen mode Exit fullscreen mode

[hooks/useModal.tsx]

interface IModalContext {
  openAlert: OpenModal<AlertProps>;
  openConfirm: OpenModal<ConfirmProps>;
}
...
 const {
    openModal: openConfirm,
    closeModal: closeConfirm,
    props: confirmProps,
    visible: confirmVisible,
  } = useDefaultModalLogic<ConfirmProps>();
...
  const modalContextValue: IModalContext = {
    openAlert,
    openConfirm,
  };

  return (
    <ModalContext.Provider value={modalContextValue}>
      {alertProps && (
        <Alert {...alertProps} onClose={closeAlert} visible={alertVisible} />
      )}
      {confirmProps && (
        <Confirm
          {...confirmProps}
          onClose={closeConfirm}
          visible={confirmVisible}
        />
      )}
      {children}
    </ModalContext.Provider>
  );
...
Enter fullscreen mode Exit fullscreen mode

[components/Examples/index.tsx]

 const openConfirmExample = () => {
    openConfirm({
      title: 'Confirm Example',
      message: 'Do you like this post?',
      cancelText: 'NO',
      confirmText: 'YES',
      onCancel: () => openAlert({ message: 'clicked NO' }),
      onConfirm: () => openAlert({ message: 'clicked YES' }),
    });
  };
Enter fullscreen mode Exit fullscreen mode

It's similar to Alert, it just gets more props, and it shows you can open another modal in a modal.

Confirm Modal

Clicked Yes in the confirm modal


A Modal that that gets no props

[components/modals/GuideModal/index.tsx]

import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';

import { IModal } from '../../../types/modal';

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const GuideModal = ({ visible = false, onClose }: IModal) => {
  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        <Typography variant="h6" component="h2">
          Guide
        </Typography>
        <Typography sx={{ mt: 2 }}>Some Text...</Typography>
        <Grid container justifyContent="flex-end">
          <Button onClick={onClose}>OK</Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default GuideModal;
Enter fullscreen mode Exit fullscreen mode

[hooks/useModal.tsx]

...
interface IModalContext {
  openAlert: OpenModal<AlertProps>;
  openConfirm: OpenModal<ConfirmProps>;
  openGuideModal: VoidFunction;
}
...
const {
    openModal: openGuideModal,
    closeModal: closeGuideModal,
    visible: guideModalVisible,
  } = useDefaultModalLogic<unknown>();
...
<GuideModal onClose={closeGuideModal} visible={guideModalVisible} />
...
Enter fullscreen mode Exit fullscreen mode

[components/Examples/index.tsx]

...
const openGuideModalExample = () => {
    openGuideModal();
  };
...
Enter fullscreen mode Exit fullscreen mode

GuideModal gets no props. So, its type of the parameter is VoidFunction, and pass unknown to a generic type ofuseDefaultModalLogic.

A modal example that has fixed content


Input Form

[components/modals/PostUploadModal/index.tsx]

import { useForm } from 'react-hook-form';
import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';

import { IModal } from '../../../types/modal';

export interface PostUploadModalProps extends IModal {
  onSubmit?: (title: string, content: string) => void;
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const PostUploadModal = ({
  visible = false,
  onClose,
  onSubmit,
}: PostUploadModalProps) => {
  const { register, handleSubmit: handleFormSubmit } = useForm<{
    title: string;
    content: string;
  }>();

  const handleSubmit: Parameters<typeof handleFormSubmit>[0] = (values) => {
    onSubmit?.(values.title, values.content);
    onClose?.();
  };

  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        <TextField
          {...register('title', { required: true })}
          sx={{ width: '100%', marginBottom: 2 }}
          label="Title"
          placeholder="Enter the title"
        />
        <TextField
          {...register('content', { required: true })}
          sx={{ width: '100%', marginBottom: 2 }}
          label="Content"
          multiline
          rows={4}
          placeholder="Enter the content"
        />
        <Grid container justifyContent="flex-end">
          <Button
            variant="contained"
            color="success"
            onClick={handleFormSubmit(handleSubmit)}
          >
            Submit
          </Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default PostUploadModal;
Enter fullscreen mode Exit fullscreen mode

[hooks/useModal.tsx]

...
interface IModalContext {
  openAlert: OpenModal<AlertProps>;
  openConfirm: OpenModal<ConfirmProps>;
  openGuideModal: VoidFunction;
  openPostUploadModal: OpenModal<PostUploadModalProps>;
}
...
const {
    openModal: openPostUploadModal,
    closeModal: closePostUploadModal,
    visible: postUploadModalVisible,
    props: postUploadModalProps,
  } = useDefaultModalLogic<PostUploadModalProps>();
...
{postUploadModalProps && (
        <PostUploadModal
          {...postUploadModalProps}
          onClose={closePostUploadModal}
          visible={postUploadModalVisible}
        />
      )}
...
Enter fullscreen mode Exit fullscreen mode

[components/Examples/index.tsx]

...
const openPostUploadModalExample = () => {
    openPostUploadModal({
      onSubmit: (title, content) => {
        openAlert({
          title: 'Form Data',
          message: `title: ${title} content: ${content}`,
        });
      },
    });
  };
...
Enter fullscreen mode Exit fullscreen mode

PostUploadModal uses react-hook-form inside, and pass the input field values to onSubmit if the values are validated.

  • Parameters<typeof handleFormSubmit>[0]: Gets first parameter type of handleFormSubmit

Input modal example

Result after submitting in the modal


A modal that calls an API

[components/modals/APICallModal/index.tsx]

import Box from '@mui/material/Box';
import Modal from '@mui/material/Modal';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Button from '@mui/material/Button';

import { IModal } from '../../../types/modal';
import { useEffect, useState } from 'react';
import { useModal } from '../../../hooks/useModal';

export interface APICallModalProps extends IModal {
  postId: number;
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '1px solid #000',
  boxShadow: 24,
  p: 4,
};

const APICallModal = ({
  visible = false,
  onClose,
  postId,
}: APICallModalProps) => {
  const [loading, setLoading] = useState(true);
  const [title, setTitle] = useState<string>('');
  const { openAlert } = useModal();

  useEffect(() => {
    const fetchPost = async (postId: number) => {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${postId}`
      );

      try {
        if (res.status !== 200) {
          throw new Error(`status is ${res.status}`);
        }

        const json = await res.json();
        setTitle(json.title);
        setLoading(false);
      } catch {
        openAlert({ message: 'API Error' });
      }
    };

    fetchPost(postId);
  }, [postId]);

  return (
    <Modal open={visible} onClose={onClose}>
      <Box sx={style}>
        <Typography variant="h6" component="h2">
          {loading ? 'loading...' : title}
        </Typography>
        <Grid container justifyContent="flex-end">
          <Button onClick={onClose}>Close</Button>
        </Grid>
      </Box>
    </Modal>
  );
};

export default APICallModal;
Enter fullscreen mode Exit fullscreen mode

[hooks/useModal.tsx]

...
interface IModalContext {
  openAlert: OpenModal<AlertProps>;
  openConfirm: OpenModal<ConfirmProps>;
  openGuideModal: VoidFunction;
  openPostUploadModal: OpenModal<PostUploadModalProps>;
  openAPICallModal: OpenModal<APICallModalProps>;
}
...
const {
    openModal: openAPICallModal,
    closeModal: closeAPICallModal,
    visible: openAPICallModalVisible,
    props: openAPICallModalProps,
  } = useDefaultModalLogic<APICallModalProps>();
...
{openAPICallModalProps && (
        <APICallModal
          {...openAPICallModalProps}
          onClose={closeAPICallModal}
          visible={openAPICallModalVisible}
        />
      )}
...
Enter fullscreen mode Exit fullscreen mode

[components/Examples/index.tsx]

...
const openAPICallModalExample = () => {
    openAPICallModal({
      postId: 1,
    });
  };
...
Enter fullscreen mode Exit fullscreen mode

I used JSONPlaceholder for the API. The modal gets an id and requests the API in useEffect.


Conclusion

Preview
Github Source Code

This is how I manage modal components in React. I'm not sure this is a good way though, I'm satisfied with it. It saves my time. I just write modal code and put the modal into the provider, and call the open function where needs it. It is kind of a rule, I follow it, that's it. I think 'there is the rule' that is the benefit.

How do you manage modal components in your project?

Top comments (0)