React TS: How I manage modal components (Custom Modal Hook)
Tags: typescript, react, beginners, webdev
Published: 2022-09-05 16:52:33

> 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.

```typescript
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;

```

And you can use this modal.

```typescript
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;
```

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]
```typescript
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>
  );
};
```

[types/modal.ts]
```typescript
export interface IModal {
  onClose?: VoidFunction;
  visible?: boolean;
}

export type OpenModal<T> = (params: T) => void;
```

[App.tsx]
```typescript
import Examples from './components/Examples';
import { ModalContextProvider } from './hooks/useModal';

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

export default App;
```

- 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]
```typescript
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;
```

[hooks/useModal.tsx]
```typescript
...
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>
  );
};
```

[components/Examples]
```typescript
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;
```

You open `Alert` modal like calling a function.

```typescript
openAlert({
  title: 'Alert Example',
  message: 'Hello Dev.to!',
});
```

if you need, you can pass the `onOk` callback function

![Alert Example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vglu0r97vetf50199xym.png)

---

## Confirm

[components/modals/Confirm/index.tsx]
```typescript
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;
```

[hooks/useModal.tsx]
```typescript
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>
  );
...
```

[components/Examples/index.tsx]
```typescript
 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' }),
    });
  };
```

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


![Confirm Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vb0qk97doejrwy5g6gnz.png)

![Clicked Yes in the confirm modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m700nll9prorsnkhyuoe.png)

---

## A Modal that that gets no props

[components/modals/GuideModal/index.tsx]
```typescript
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;
```

[hooks/useModal.tsx]
```typescript
...
interface IModalContext {
  openAlert: OpenModal<AlertProps>;
  openConfirm: OpenModal<ConfirmProps>;
  openGuideModal: VoidFunction;
}
...
const {
    openModal: openGuideModal,
    closeModal: closeGuideModal,
    visible: guideModalVisible,
  } = useDefaultModalLogic<unknown>();
...
<GuideModal onClose={closeGuideModal} visible={guideModalVisible} />
...
```

[components/Examples/index.tsx]
```typescript
...
const openGuideModalExample = () => {
    openGuideModal();
  };
...
```

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

![A modal example that has fixed content](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/spkvsu2ic5acu0t1b3yk.png)

---

## Input Form

[components/modals/PostUploadModal/index.tsx]
```typescript
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;
```

[hooks/useModal.tsx]
```typescript
...
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}
        />
      )}
...
```

[components/Examples/index.tsx]
```typescript
...
const openPostUploadModalExample = () => {
    openPostUploadModal({
      onSubmit: (title, content) => {
        openAlert({
          title: 'Form Data',
          message: `title: ${title} content: ${content}`,
        });
      },
    });
  };
...
```

`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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/trohqtotoqqeboqja4rj.png)

![Result after submitting in the modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0anxmnvnjxjho1kun7yy.png)

---

## A modal that calls an API

[components/modals/APICallModal/index.tsx]
```typescript
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;
```

[hooks/useModal.tsx]
```typescript
...
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}
        />
      )}
...
```

[components/Examples/index.tsx]
```typescript
...
const openAPICallModalExample = () => {
    openAPICallModal({
      postId: 1,
    });
  };
...
```

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

---

## Conclusion

[Preview](https://hsk-kr.github.io/react-custom-modal-hooks/)
[Github Source Code](https://github.com/hsk-kr/react-custom-modal-hooks)

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?