[React] 전역으로 관리하는 모달 만들기

이준희·2021년 9월 14일
0

REACT 정복기

목록 보기
6/9

뭘 만든다는 건데? 🧐

이번에 만들어 볼 것은 모달인데, ios에서 기본적으로 제공하는 것처럼 생긴 모달을 만들 것입니다!

먼저 완성본부터 보겠습니다

디자인에 관련된 정확한 측정이 스스로의 힘으로는 불가능하였지만, 같이 협업하고 계신 디자이너님께서 만들어서 제공해주셨습니다👍

프론트 작업 시에, 기본적으로

① POST 요청 (글 작성하기)
② GET 요청 (글 불러오기)

가 완료되었다면 우리는 CRUD(Create Read Update Delete)UpdateDelete를 이루기 위한 발판인 환경설정을 제공해야 할 것입니다.

아직까지 Update, Delete와 관련된 라우팅 작업이 완료되지 않은 상황입니다

따라서 환경설정을 통해 수정과 삭제 로직을 추가하기 위한 관련 모달을 작업해보도록 하겠습니다.

폴더구조

아래의 폴더 구조는 코드의 이해를 돕기 위해 만든 구조로 실제 코드 구조와는 다릅니다..
① 이런식으로 마크업을 했구나.. ② 리듀서를 이렇게 적용하는구나.. ③ 이렇게 전역으로 관리할 수 있구나..
를 봐주시면 감사하겠습니다 😁

- components
  - header/index.tsx
  - IsModal/index.tsx
- pages
  - main.tsx
- reducers
  - common/index.tsx

분석하기

디자인 분석

현재 애플리케이션은 다음과 같이 3개로 나누어져있습니다.

이번 과제는 ① header 부분의 환경설정 버튼을 눌렀을 때 ① header, ② main, ③ navbar 전체를 덮는 모달을 만들어야 합니다.

기술 분석

크게 생각하면 로직의 단계는 다음과 같습니다.

① 클릭 이벤트로 모달 관리하기
② 리듀서를 통해 전역으로 관리할 수 있는 만들기
③ 메인 페이지에 적용하기

① 클릭 이벤트로 모달 관리하기

우선 아이콘을 눌렀을 때 전체를 덮을 수 있는 모달을 만들어야 합니다.

useState를 사용한다면 하위 컴포넌트로 넘겨주기 쉽지만, 복잡하거나 상위 컴포넌트로 넘겨줘야 하는 상황이 생길 수 있기 때문에 reducer를 통해 전역으로 state를 관리해보도록 하겠습니다.

header 부분에서 아이콘 클릭을 통해 모달에 접근해야 하므로, 헤더 컴포넌트 부분에 해당 로직을 추가해줍니다.

📁 components/header.tsx

import { useCallback } from 'react'
import { useDispatch } from 'react-redux'
import { SET_ISMODAL } from 'reducers/common'

// useDispatch 사용하기
const dispatch = useDispatch()

// 리듀서로 요청 보내기
  const setModal = useCallback(() => {
    dispatch({
      type: SET_ISMODAL,
    })
  }, [dispatch])

// 클릭 이벤트에 디스패치 함수 바인딩하기
<button type="button" onClick={setModal}>
   <img src="/images/header/setting.svg" alt="환경설정" />
</button>
...

기본적으로 모달을 만들기 위해서는 해당 화면을 전부 덮어야 합니다.

앞서 말했던 header / main / navbar 부분 전부를 말이죠

이를 염두하여 IsModal 컴포넌트를 만들어봅니다

그림으로 쉽게 구조를 잡자면 다음과 같습니다.

📁 components/isModal.tsx
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useCallback } from 'react'
import { css } from '@emotion/react'
import { useDispatch } from 'react-redux'
import { SET_ISMODAL } from 'reducers/common'

const IsModal = () => {
  const dispatch = useDispatch()

  const setModal = useCallback(
    (e) => {
      e.stopPropagation()
      dispatch({
        type: SET_ISMODAL,
      })
    },
    [dispatch]
  )

  const updatePost = useCallback((e) => {
    e.stopPropagation()
    console.log('수정')
  }, [])

  const removePost = useCallback((e) => {
    e.stopPropagation()
    console.log('삭제')
  }, [])

  return (
    // eslint-disable-next-line jsx-a11y/no-static-element-interactions
    <div css={modalWrap} onClick={setModal}>
      <div css={modalBtnWrap}>
        <div css={modalInner}>
          <button type="button" onClick={updatePost}>
            수정
          </button>
          <button type="button" onClick={removePost}>
            삭제
          </button>
        </div>
        <div css={modalInner}>
          <button type="button" onClick={setModal}>
            취소
          </button>
        </div>
      </div>
    </div>
  )
}

export default IsModal

const modalWrap = css`
  background-color: rgba(196, 196, 196, 0.6);
  /* background-color: rgba(0, 0, 0, 0.5); */
  width: 100%;
  height: 100vh;
  z-index: 10500;
  position: absolute;
  top: 0;
`

const modalBtnWrap = css`
  padding: 20px 20px 28px 20px;
  display: block;
  position: absolute;
  bottom: 0px;
  width: 100%;

  button + button {
    margin-bottom: 10px;
  }

  div + div {
    background-color: #fff !important;
  }
`

const modalInner = css`
  display: block;
  background-color: #f1f1f1;
  border-radius: 14px;

  button + button {
    border-top: 1px solid rgba(196, 196, 196, 0.6);
    /* border-top: 1px solid rgba(0, 0, 0, 0.5); */
    color: #fb4843 !important;
  }

  button {
    color: #1c81fa;
    padding: 18px;
    display: block;
    width: 100%;
    font-size: 18px;
  }
`

상황에 따라 다를 수 있지만 모달로 전체를 덮기 위해서는 modalWrap과 같이 position을 absolute 즉, 절대값으로 준 뒤 어디에 위치할지에 대한 포지셔닝 (top, bottom, left, right)에 대한 명시가 필요합니다.

저는 위에서부터 전체를 덮는 형태이기 때문에

  • top에는 0 (header 부터 덮기 위해서)
  • 애플리케이션 전체를 덮기 위해 height는 100vh를 주었습니다

② 리듀서를 통해 전역으로 관리할 수 있는 모달 만들기

따로 서버와의 Ajax 요청이 필요한 작업이 아니기 때문에, reducer에만 로직을 구성하였습니다.

컴포넌트에서 useState를 통해 관리한다면


const [isModal, setIsModal] = useState(false)

와 같은 로직이 될 수 있겠죠?

하지만 이번에는 조금 더 깊게 나가 리듀서를 통해 작성해보았습니다.

📁 reducers/common/index.tsx

import produce from 'immer'

export interface CommonState {
  isModal: boolean
}

// initialState 정의
export const initialState: CommonState = {
  isModal: false,
}

// 액션 정의
export const SET_ISMODAL = 'SET_ISMODAL' as const

// 액션에 대한 타입 정의;
export interface SetIsModal {
  type: typeof SET_ISMODAL
}

// 리듀서 안에 들어갈 액션 타입에 대한 액션 생성 함수 정의
export const setIsModal = (): SetIsModal => ({
  type: SET_ISMODAL,
})

export type SetCommon = ReturnType<typeof setIsModal>

const common = (state = initialState, action: SetCommon) =>
  produce(state, (draft) => {
    switch (action.type) {
      case SET_ISMODAL: {
        draft.isModal = !state.isModal
        break
      }

      default:
        return state
    }
  })

export default common

주의깊게 보실 수 있는 부분은 immer를 통해 불변성을 유지하지 않으면서 값을 관리하도록 만들었습니다.

① 액션 또는 ② 액션 생성함수를 dispatch 한다면,

  • 리듀서 내부를 동작하여 현재 false의 초기값을 가지고 있는 false 를 true 로 바꿀 것입니다
  • 다시 한 번 함수가 실행된다면 true가 false가 될 것입니다.

만약 리듀서의 종류가 많아져, 단일 리듀서를 사용할 수 없는 상황이라면 combinedReducer를 통해 리듀서를 합쳐줄 수 있습니다.

import { combineReducers } from 'redux'
import common from './common'

const rootReducer = combineReducers({
  common,
})

export default rootReducer

export type RootState = ReturnType<typeof rootReducer>

③ 메인 렌더링 페이지에 적용하기

리듀서 작성이 완료되었다면, 메인 페이지에서 useSelector를 통해 reducer에 작성된 initialState를 불러와야 겠죠?

이제 이를 적용해보도록 하겠습니다.

<function Main() {
  const { isModal } = useSelector((state: RootState) => state.common)
  
  ...
  return(
  	<>
	  {isModal && <IsModal />}
	</>
  )
}

export default Post

결과 확인하기

마치며

기존에는 자바스크립트를 기반으로 해서 설명하는데 문제가 없었는데, 타입스크립트가 적용된 코드를 공유하다보니 설명에 있어서 막히는 부분이 생기네요 🥲

코드를 복붙하면 실행이 전혀 안될겁니다..

또한 e.stopPropagation()을 통해 취소 버튼 클릭외에도 배경 클릭시에도 모달창을 종료할 수 있게 구성하였는데 그 부분에 대한 설명도 많이 부족한 것 같습니다.

두 장 요약 event1 🔥
두 장 요약 event2 🔥

를 통해 이벤트에 관한 내용을 같이 공부하신다면 stopPropagation이 무엇인지 더욱 자세히 알 수 있습니다!

profile
https://junheedot.tistory.com/ 이후 글 작성은 티스토리에서 보실 수 있습니다.

0개의 댓글