✈️ 리액트 Hooks

카테고리 React, Hooks

📌 Hooks

리액트 Hook은 클래스로 컴포넌트를 만들 때 발생하는 문제점을 해결하기 위해 등장하였다.

  • 리액트 Hook을 사용하면 함수 컴포넌트 중심으로 개발이 가능하다.
  • 함수 컴포넌트에서도 상태, 로직을 추출하여 다른 컴포넌트에서 재사용 할 수 있다.
계속 읽기

📌 미션

  • Link 역할로써 span태그, img태그 확인
  • tabindex=”0”를 포함
  • img태그는 alt 속성으로 접근 가능한 이름 정의

🐒 문제

🤿 컴포넌트 props “as”를 img로 주면, img 태그로 렌더링 해주기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import styled from "styled-components";
import { goToLink } from "../../utils";

export function Link({ as = ComponentName, href, children }) {
const StyledLink = styled(`${as}`)`
... styles
`;
return (
<>
<StyledLink
tabIndex="0"
role="link"
onClick={(e) => goToLink(e, href)}
onKeyDown={(e) => goToLink(e, href)}
>
{children}
</StyledLink>
</>
);
}

Link.defaultProps = {
as: "span",
};
  • Link 컴포넌트 안에다가 styled-component를 생성하고 as props를 전달해주었다.

  • 위와 같이 할 경우, as props에 img 태그가 들어오게 된다면 컴포넌트 명명 규칙에 어긋난다고 나온다.

    이를 해결하기 위해 조건부 렌더링을 해주었다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    import styled from "styled-components";
    import { goToLink } from "../../utils";

    export function Link({ as = ComponentName, href, children }) {
    const StyledLink = styled(`${as}`)`
    ... styles
    `;
    return (
    <>
    {as === "img" ? (
    <img
    tabIndex="0"
    role="link"
    onClick={(e) => goToLink(e, href)}
    onKeyDown={(e) => goToLink(e, href)}
    src="./gitprofile.jpeg"
    alt="W3C Website"
    />
    ) : (
    <StyledLink
    tabIndex="0"
    role="link"
    onClick={(e) => goToLink(e, href)}
    onKeyDown={(e) => goToLink(e, href)}
    >
    {children}
    </StyledLink>
    )}
    </>
    );
    }

    Link.defaultProps = {
    as: "span",
    };

    🐥 img가 업로드 되지 않는 문제

    • img 업로드 문제 해결

    문제의 원인은 npm 명령어로 webpack server만 실행시켰는데, webpack config.js 파일에 static 속성값을 [build]로 주었기 때문에 정적 이미지 파일을 불러오기 위해서는 build 폴더에서 assets을 찾는다.

    그러므로 npm run build 명령어로 build 폴더를 생성한 후 그 안에다가 img를 넣어주고 경로를 설정해주어 해결하였다.

    1
    2
    3
    4
    5
    6
    <Link
    as="img"
    alt="test이미지"
    src="./assets/img/gitprofile.jpeg"
    href="https://www.w3.org/WAI/ARIA/apg/example-index/link/link.html"
    />

    🐥 aria-label 속성 있을 때, 조건부로 css 추가 (삭제)

    이 속성을 삭제한 이유는 span 태그를 사용하여 Link 컴포넌트를 구현하였을 때, role="link"로 해주었기 때문에 aria-label 속성을 굳이 해주지 않아도 되므로 삭제하였다.

🏓 회고

  • styled-component를 사용하면서 어떻게 props를 받아서 App에서 렌더링 시켜야할지 헷갈렸었는데 직접 해보니 이해가 잘되었다.
  • 수업 시간에 배웠던 조건부 렌더링을 실제로 사용하면서 익히니 더욱 이해가 잘되었다. 역시 무언가를 만들어보면서 배우는 것이 힘들지만 더 도움되는 것 같다.

댓글 공유

🎧 PropTypes

카테고리 React

🌈 PropTypes

리액트 컴포넌트에 전달되는 Prop(속성)의 Type(타입)을 검사하는 방법에 대해 알아보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
EmotionCard.propTypes = {
// 전달 속성 객체, 속성 이름, 컴포넌트 이름
emotion(props, propName, componentName) {
// 체크할 유형
const checkType = "string";
// 전달 속성 유형
const propType = typeof props[propName];

// 전달 속성 검사 (문자 값인지 확인)
if (propType !== checkType) {
// 문자 값이 아닌 경우 오류 발생
throw new Error(
`${componentName} 컴포넌트에 전달 된 속성 ${propName}의 데이터 유형은 ${checkType}이 요구되나, 실제 전달된 속성 유형은 ${propType}이니 확인 바랍니다.`
);
}
},
};

위와 같이 직접 custom propTypes를 통해 prop를 검사할 수 있지만 리액트 팀에서 제공하는 propTypes 패키지를 사용하면 편리하고 안정적으로 prop 검사를 할 수 있다.

패키지 사용

설치

1
npm i -D prop-types

사용 예시

1
2
3
4
5
6
7
8
9
10
import PropTypes from "prop-types";

// ...

Worker.propTypes = {
name: PropTypes.string.isRequired,
career: PropTypes.number,
onCareerUp: PropTypes.func,
isLeave: PropTypes.bool,
};
  • isRequired 는 필수입력을 요구하는 속성을 표시한다.

propTypes 검사 항목

propType_categories

PropTypes﹕objectOf vs. shape

1
2
3
4
5
6
7
8
9
10
import { objectOf, number } from "prop-types";

const geoProps = {
latitude: 37.331706,
longitude: -122.030783,
};

ReactComponent.propTypes = {
geoProps: objectOf(number),
};
  • propTypes.objectOf객체의 속성 값이 모두 동일한 타입을 설명할 경우 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
import { shape, arrayOf, string } from "prop-types";

const personProp = {
name: "야무",
job: ["강사", "디자이너", "개발자"],
};

ReactComponent.propTypes = {
personProp: shape({
name: string,
job: arrayOf(string),
}),
};
  • 내가 객체 prop 타입 설정하고 싶다하면 shape를 사용한다.
  • propTypes.shape 는 객체의 각 속성별 타입을 설명할 때 사용한다.
  • 객체의 name 속성은 string, job 속성은 배열을 사용하였다.
  • job의 배열에는 string으로만 구성된 배열이므로 arrayOf를 사용하였다.

객체의 속성이 정확히 동일하게 일치해야한다면 propTypes.exact()를 사용한다.

❗️ Null 타입 체크 주의

propTypes는 null 타입 체크를 할 수 없어 oneOf를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
import { oneOf, oneOfType, shape, string } from "prop-types";

SignInedInfo.propTypes = {
authUser: oneOfType([
oneOf([null]), // ← 이렇게 작성합니다.
shape({
displayName: string.isRequired,
photoURL: string.isRequired,
}),
]).isRequired,
};
  • PropTypes.oneOf([’grow’, ‘learn’, ‘connect’]) 이 중 하나만 파라미터로 들어올 수 있다.

🔥 defaultProps와 같이 사용하기

함수에 기본값을 주듯이 props에 기본값을 주는 것을 리액트에서는 지양한다. 그러므로 다음과 같이 기본값을 줘야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react';
import { number, bool}

const Worker = ({ name, career, onCareerUp, isLeave }) => (
// ...
);

// Worker 컴포넌트 전달 속성 기본 값 설정
Worker.defaultProps = {
career: 0,
isLeave: true,
}

Worker.propTypes = {
career:number,
isLeave: bool
}

export { Worker };
  • boolean 대신 bool이라고 사용합니다.

댓글 공유

애플리케이션의 비동기 처리는 빈번하게 발생하므로 비동기 요청의 응답을 기다리는 동안 사용자에게 로딩을 표시해줘야한다. 재사용이 가능한 컴포넌트로 로딩 컴포넌트를 만들어보자.

✏️ 접근성 고려

접근성을 고려하였을 때, 스크린 리더가 로딩중이 시작할 때와 로딩이 종료되었을 때를 읽을 수 있도록 하기 위해서는 public 폴더에 index.html 파일에 다음과 같이 기재가 되어있어야한다.

1
2
3
4
5
6
// public/index.html
<body>
<!-- 로딩 스피너 접근성을 위한 DOM 요소를 추가하세요. -->
<div id="loading-start" aria-live="assertive"></div>
<div id="loading-end" aria-live="assertive"></div>
</body>
  • aria-live="assertive"속성을 주어 다른 것보다 우선적으로 스크린 리더가 읽도록 설정해준다.

예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Spinner.js
const loadingElements = {
start: document.getElementById("loading-start"),
end: document.getElementById("loading-end"),
};

export class Spinner extends React.Component {
static defaultProps = {
type: "connect", // 'grow', 'learn', 'connect'*
message: "로딩 중...",
showMessage: true,
timeToDisappear: {
start: 1500,
end: 2500,
},
};

render() {
const { type, message, showMessage } = this.props;
const spinnerImagePath = getAsset(`spinner/spinner-${type}.gif`);

return (
<figure className={styles.container}>
<img className={styles.image} src={spinnerImagePath} alt="" />
{showMessage ? (
<figcaption>{message}</figcaption>
) : (
<A11yHidden as="figcaption">{message}</A11yHidden>
)}
</figure>
);
}

componentDidMount() {
const { start } = loadingElements;
start.setAttribute("role", "alert");
start.insertAdjacentHTML(
"beforeend",
`<span class="a11yHidden">${this.props.message}</span>`
);
}

componentWillUnmount() {
const { start, end } = loadingElements;
const { timeToDisappear } = this.props;

setTimeout(() => {
start.removeAttribute("role");
start.innerHTML = "";
end.insertAdjacentHTML(
"afterbegin",
`<span class="a11yHidden">로딩이 종료되었습니다.</span>`
);
}, timeToDisappear.start);
setTimeout(() => {
end.innerHTML = "";
}, timeToDisappear.end);
}
}
  1. loading 요소를 반복적으로 사용할 것이기 때문에 최상단에 객체의 프로퍼티로 등록시켜주었다.
  2. Spinner 컴포넌트의 기본 props값을 설정해주었다.
    • 이는 컴포넌트를 만든 사람만 알 수 있기때문에 문서화를 하거나 TypeScript를 사용하여 개발자 경험(DX)를 높일 수 있다.
  3. 로딩중이라는 메시지를 보여주는 경우와 그렇지 않는 경우를 나누었다. 보여주지 않는 경우에는 접근성 컴포넌트로 생성하여 스크린 리더에는 읽히도록 설정해주었다.
  4. 컴포넌트가 mounted 될 때, role="alert"속성을 주어 스크린 리더가 읽고 있는 것을 중지하고 로딩중을 읽도록 설정하였다.
  5. StrictMode 에서는 mounted - unmounted - mounted 되는 특징때문에 2번 작동할 수 있으므로 성능을 고려하여 clean Up을 해줘야한다.

댓글 공유

Portal(포털)

카테고리 React

📌 Portal이란?

포털을 사용하면 애플리케이션 영역을 벗어나 특정 위치에 컴포넌트를 렌더링할 수 있다. 즉, 지금껏 root 컨테이너에만 렌더링을 해왔다면 포털을 사용하여 root 컨테이너 외부에다가도 컴포넌트를 렌더링할 수 있게된다.

포털을 통해 렌더링된 컴포넌트는 DOM 트리 위치와 상관없이 React 컴포넌트 트리에 포함되기 때문이다.

🔨 사용방법

1
ReactDOM.createPortal(child, container);
  • child는 렌더링할 수 있는 자식
  • container는 DOM 요소이다.
1
2
3
4
5
6
render() {
return ReactDOM.createPortal(
this.props.children,
domNode
)
}
  • 위 경우 React는 새로운 div를 생성하지 않고 domNode 안에 자식을 렌더링한다.
  • domNode는 DOM 내부의 어디에 있던지 간에 상관없다.

시각적으로 자식을 튀어나오도록 보여야하는 다이얼로그, 호버카드, 툴팁에 사용된다. 이 때, 키보드 포커스 관리와 접근성을 고려해줘야한다.

🌈 다이얼로그 예시

다이얼로그 특징

  1. 다이얼로그가 띄워진 상태면 그 아래 위치한 내용은 비활성화 상태여야한다.
  2. 다이얼로그 바깥으로 초점이동되면 안된다.
  3. dialog 역할(role)을 부여해야한다.
  4. 모달 기능일 경우 aria-modal=true 이여야한다.
  5. 다이얼로그 제목은 aria-label, aria-labelledby로 설정한다.

1. 부모 컴포넌트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export class DemoDialog extends React.Component {
#opennerRef = React.createRef(null);

state = {
show: false,
};

handleShowDialog = () => {
this.setState({ show: true });
};

handleHideDialog = () => {
this.setState({ show: false });
};

render() {
return (
<div className={styles.box}>
<button
ref={this.#opennerRef}
type="button"
className={styles.openDialogButton}
aria-haspopup="dialog"
aria-label="모달 다이얼로그 열기"
title="모달 다이얼로그 열기"
onClick={this.handleShowDialog}
>
모달열기
</button>
{this.state.show && (
<Dialog
modal
onClose={this.handleHideDialog}
openner={this.#opennerRef.current}
>
<Dialog.Header>
<h3>불금 다이얼로그</h3>
</Dialog.Header>
<Dialog.Body>
<ul>...</ul>
</Dialog.Body>
</Dialog>
)}
</div>
);
}
}
  • 부모 컴포넌트에서는 버튼 클릭이벤트로 모달을 조건부 렌더링해주고 있다.

2. 모달 컴포넌트(자식)

root 요소가 아닌 곳에 자식 컴포넌트를 그려주기 위해서 public 폴더의 index.html에 다음과 같이 div 요소를 추가해줘야한다.

1
2
3
4
5
6
7
// public/index.html
<body>
<div id="root"></div>

<!-- 다이얼로그를 렌더링 할 컨테이너 요소 -->
<div id="dialogZone"></div>
</boyd>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Dialog.jsx
const { documentElement: htmlElement } = document;
const reactDomContainer = document.getElementById("root");

export class Dialog extends React.Component {
#containerRef = React.createRef(null);

handleClose = () => {
this.props.onClose?.();
this.props.openner.focus();
};

render() {
const { modal = false } = this.props;

return createPortal(
<>
<article
ref={this.#containerRef}
tabIndex={-1}
role="dialog"
aria-modal={modal}
className={styles.container}
>
{this.props.children}
<Dialog.Footer onClose={this.handleClose} />
</article>
{modal && <div className={styles.dim} onClick={this.handleClose} />}
</>,
document.getElementById("dialogZone")
);
}
}
  • 우선 해당 모달 컴포넌트를 그릴 domNode를 htmlElement로 지정해주었다.
  • 컨테이너 DOM 요소 가져오기 위해서 ref를 생성해주었다.
  • createPortal()에 렌더링할 자식 컴포넌트와 자식 컴포넌트를 렌더링할 컨테이너를 전달하였다.
  • 모달이 켜져있을 때, Dialog.footer 자식 요소에게 onClose를 props로 전달하였다.

3. 자식 컴포넌트 접근성 고려

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
export class Dialog extends React.Component {
...

#tabbableElements = [];

#bindEscKeyEvents() {
const handler = (e) => {
if (e.key.toLowerCase().includes("escape")) {
console.log("pressed esc key");
this.handleClose();
}
};

document.addEventListener("keyup", handler);

// cleanup function
return () => document.removeEventListener("keyup", handler);
}

#unbindEscKeyEvents = null;

componentDidMount() {
this.#containerRef.current.focus();
this.#tabbableElements = getTabbableElements(this.#containerRef.current);
this.settingKeyboardTrap();

htmlElement.style.overflowY = "hidden";
reactDomContainer.setAttribute("aria-hidden", true);

this.#unbindEscKeyEvents = this.#bindEscKeyEvents();
}

componentWillUnmount() {
htmlElement.style.overflowY = "visible";
reactDomContainer.setAttribute("aria-hidden", false);

this.#unbindEscKeyEvents?.();
}

settingKeyboardTrap() {
const tabbles = this.#tabbableElements;
const firstElement = tabbles[0];
const lastElement = tabbles[tabbles.length - 1];

firstElement.addEventListener("keydown", (e) => {
if (e.shiftKey && e.key.toLowerCase().includes("tab")) {
e.preventDefault();
lastElement.focus();
}
});

lastElement.addEventListener("keydown", (e) => {
if (!e.shiftKey && e.key.toLowerCase().includes("tab")) {
e.preventDefault();
firstElement.focus();
}
});
}
}
  • key 이벤트를 등록해주고 제거해줄 때, 클로저를 사용하여 이벤트 핸들러를 동일한 참조값으로 일치시켜주면 편하다.
    • keydown 이벤트를 사용해야지만 e.preventDefault()기본동작을 막을 수 있다.
  • 모달 컴포넌트가 생성되었을 때, 해당 컨테이너(article)에 초점을 가게하기 위해 ref를 전달해준 것이다.
  • htmlElement.style.overflowY = "hidden"로 모달 컴포넌트가 띄워져있을 때, 다른 요소는 스크롤이 비활성화시켜주었다.
  • 모달이 켜져있으면 root 컨테이너는 aria-hidden을 스크린 리더기에서도 모달만 읽히도록 해줘야한다.
  • 컴포넌트가 소멸되기 직전에 등록했던 key 이벤트를 제거해줘야하는 것을 잊지 말자. (고려하지 않는다면 성능에 문제가 생길 것이다.)

4. 모달 slot 구분

어떤 컴포넌트들은 어떤 자식 요소가 들어올지 예상할 수 없는 경우가 있다. 이럴 경우 children prop을 사용하여 자식 요소를 출력에 그대로 전달하는 것이 좋다.

합성(composition)을 사용하여 컴포넌트 간에 코드를 재사용하도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Dialog.Header = function DialogHeader({ children }) {
return <header className={styles.header}>{children}</header>;
};

Dialog.Header.defaultProps = {
children: <h2>React 포털로 연 다이얼로그(with 모달)</h2>,
};

Dialog.Body = function DialogBody({ children }) {
return <div className={styles.body}>{children}</div>;
};

Dialog.Footer = function DialogFooter({ children, onClose }) {
return (
<footer className={styles.footer}>
<button
type="button"
className={styles.closeButton}
aria-label="모달 다이얼로그 닫기"
title="모달 다이얼로그 닫기"
onClick={onClose}
>
모달닫기
</button>
{children}
</footer>
);
};

댓글 공유

DOM 컨트롤(ref)

카테고리 React

🕹 리액트 DOM 컨트롤

리액트 앱은 대부분 리액트에 의해 컨트롤되지만 예외인 상황이 있다. 리액트는 virtualDOM을 가지고 동작하기 때문에 RealDOM 요소에 접근하거나 조작해야할 경우 이는 리액트가 할 수 없는 일이다.

이러한 일을 리액트 사이드 이펙트라고 부른다. 흔히들 사이드 이펙트를 부작용이라고 오인하는 경우가 있지만 여기서는 순수하지 않거나 역할에 맞는 일을 하지 않는 경우를 말한다.

사이드 이펙트를 처리하기 위해서는 다음과 같은 React API를 사용하여야한다.

  • ref(참조 설정)
    • 값이 변경되어도 리액트가 재렌더링하지 않는다.
  • callback ref(ref 속성에 연결된 함수)
  • React.createRef(참조 객체 생성)
  • React.forwardRef(참조 객체 전달)
    • 범용적으로 누구나 사용할 수 있는 컴포넌트를 만들기 위해서 꼭 필요하다.
    • 외부 컴포넌트가 내부 컴포넌트를 전달받아서 컴포넌트를 내보낸다.
    • domRef를 통해서 전달할 수도 있다.

💭 예시

컴포넌트 생성 시점에 이벤트를 구독한 경우, 컴포넌트 제거 시점에 구독한 이벤트를 취소해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export class TiltCard extends React.Component {
// DOM 요소 참조를 목적으로 Ref를 생성합니다.
tiltRef = React.createRef(null); // { current:null }

// DOM 요소 참조를 위한 목적의 클래스 인스턴스 멤버
#cardDomElement = null;

// 최초 마운트 시점 이후 처리할 로직을 작성합니다.
componentDidMount() {
this.#cardDomElement = this.tiltRef.current;
VanillaTilt.init(this.#cardDomElement);

// 이벤트 구독
this.#cardDomElement.addEventListener(
"tiltChange",
this.handleChangeTilt.bind(this)
);
}

// 마운트 해제 직전에 처리할 로직을 작성합니다.
componentWillUnmount() {
this.#cardDomElement.vanillaTilt.destroy();

// 이벤트 구독 해지
this.#cardDomElement.removeEventListener(
"tiltChange",
this.handleChangeTilt.bind(this)
);
}

render() {
const { children } = this.props;

return (
// 생성된 Ref를 참조하도록 설정합니다.
<div ref={this.tiltRef} className="tiltCard">
{children}
</div>
);
}
}
  • tiltRef에 DOM 요소를 저장하기 위해 ref를 생성하였다.
  • tiltRef DOM 요소 얻기 위해 “current” 프로퍼티에 접근하였다.
  • tiltRef DOM 요소에 컴포넌트 생성주기에 맞게 이벤트를 등록하고 제거해주었고 ref를 참조하도록 설정해주었다.

댓글 공유

LifeCycle(라이프사이클)

카테고리 React

📌 LifeCycle이란?

라이프 사이클은 함수 컴포넌트와 달리 클래스 컴포넌트에서만 라이프 사이클이 제공된다. 컴포넌트 생성, 마운트, 업데이트, 언마운트 등 특정 시점에서 실행되는 콜백함수를 말한다.

👑 LifeCycle 필요성

라이프 사이클은 React의 성능을 최적화하고 React가 컨트롤할 수 없는 Side Effect(사이드이펙트)를 처리하기 위함이다.

대표적인 사이드 이펙트는 다음과 같다.

  1. 네트워크 통신 (비동기 통신 요청 및 응답)
  2. DOM 컨트롤 (RealDOM 접근 및 조작)
  3. 구독/취소 (이벤트 핸들링 등)
  4. 오류 감지 (ErrorBoundary 컴포넌트 등)

✏️ 대표적인 LifeCycle 메서드

  • constructor()
    • 마운트 되기 전에 실행된다.
  • render()
    • 컴포넌트 렌더링 시점에 호출
  • componentDidMount()
    • DOM에 마운트 된 이후 실행된다.
  • componentDidUpdate()
    • 최초 렌더링 시 실행 ❌
    • 업데이트 이후 실행
  • componentWillunmount()
    • 컴포넌트 소멸하기 직전에 실행

LifeCycle

🌈 Render 단계

  • render 단계는 virtualDOM을 조작하는 단계이다.
    • 성능 최적화를 위한 행동은 render 이전에 해줘야한다.
    • render가 끝났다고 해서 실제DOM에 바로 반영되는 것이 아니다.
  • 리액트 업데이트가 되는 경우는 다음 3가지 상황이다.
    1. 새로운 props가 전달되었을 때,
    2. 해당 컴포넌트의 state가 변경되었을 때,
    3. 강제로 변경시켰을 때만 업데이트가 발생한다.

🔨 Commit 단계

commit 단계는 RealDOM이므로 명령형이 가능하다.

  • DOM을 사용하여 componentDidMount, DidUpdate, WillUnmount 에서 명령형 코드 작성이 가능하다.
  • 사이드 이펙트 관련 함수는 여기서 수행해야한다.

댓글 공유

🐍 setState

카테고리 React, Hooks

📌 setState

setState() 메서드는 컴포넌트의 state 객체에 대한 업데이트를 실행합니다. state가 변경되면, 컴포넌트는 리렌더링됩니다.

1
setState(updater[, callback])

updater에 컴포넌트의 state 변경사항을 인자로 넘겨주고 React에게 해당 컴포넌트를 재렌더링해야한다고 요청을 보내는 메서드이다.

  • setState() 호출은 비동기적으로 이뤄진다.
  • 성능 향상을 위해 즉각적으로 재렌더링을 발생시키지 않는다.
    • 여러 변경 사항을 일괄적으로 갱신하거나 나중에 미룰 수 있다. 때문에 setState() 호출하자마자 this.state에 접근하는 것은 문제가 될 수 있다.
    • 대신 componentDidUpdate() 또는 setState(updater, callback)처럼 2번째 인자로 콜백함수를 전달하는 방법을 사용한다.
1
2
3
4
5
6
7
8
9
10
this.state = {
message: "",
};

this.setState(
{ message: "상태 변경" },
() => console.log("callback: ", this.state.message) // '상태 변경'
);

console.log(this.state.message); // ''
  • 아직 렌더링이 발생하지 않았기 때문에 this.state.message의 값이 갱신되지 않았다.

🎒 2022.11.12 추가

setState() 첫번째 인수로 콜백함수 올 때

1
2
3
4
5
6
7
8
9
10
11
12
13
export class BinaryCalcurator extends Component {
state = {
buttonStates: Array(this.props.numberOfButtons).fill(false),
};

handleToggleButtonContent = (index) => {
this.setState(({ buttonStates }) => ({
buttonStates: buttonStates.map((buttonState, idx) =>
idx === index ? !buttonState : buttonState
),
}));
};
}
  • 콜백함수는 이전 상태를 받아서 상태 변경을 요청한다.

댓글 공유

🛤 Class Component vs Function Component

과거의 리액트는 상태를 가지는 컴포넌트는 클래스형으로, 상태를 가지지 않는 컴포넌트는 함수형으로 작성하였다. 왜 그랬을까?

class&function-components

Container 컴포넌트는 상태를 가지는데, 함수형 컴포넌트로 생성하게 되면 함수 호출하여 컴포넌트를 생성할 때 마다 상태가 초기화된 값을 가지기 때문에 Container 컴포넌트는 클래스형으로만 작성하였다.

  • this를 사용하기 때문에 클래스형 컴포넌트가 생성방식이 더 복잡하다.
  • 클래스형 컴포넌트에는 render() 메서드가 꼭 있어야 한다.
  • 클래스형 컴포넌트에서 이벤트 등록 시 this 바인딩을 꼭 해줘야만 한다.

📌 stateless 컴포넌트

stateless 컴포넌트는 말 그대로 상태를 가지지 않는 컴포넌트이다. 이러한 컴포넌트를 stateless 컴포넌트 또는 presentational(프레젠테이셔널) 컴포넌트라고 한다.

목적

  • 시각적 표현에 중점을 둔 컴포넌트
  • 시각적 표현을 목적으로 하니 복잡한 비즈니스 로직 필요없다
  • 상태를 가질 필요가 없고 외부에서 전달 받은 데이터를 화면에 그려준다.

✏️ stateless 컴포넌트는 함수형으로 작성한다?

표현을 목적으로 하는 컴포넌트는 복잡한 비즈니스 로직이 필요없고 이를 클래스로 작성할 경우 Babel 컴파일러가 클래스를 컴파일 해줄 때 함수형 컴포넌트일 때 보다 훨씬 더 많은 코드로 컴파일 해주기 때문에 stateless 컴포넌트를 클래스형으로 작성하는 것은 낭비이다.

📌 stateful 컴포넌트

상태를 가지는 컴포넌트를 stateful 컴포넌트 또는 Container(컨테이너) 컴포넌트라고 한다.

목적

  • 비즈니스 로직을 가지고 있다.
  • state(상태)를 가진다.
  • Presentational 컴포넌트에게 Props를 전달하여 UI 렌더링 하도록 한다.
  • 화면을 그리는 최소한의 스타일 정보를 가진다.

❓ stateful 컴포넌트는 함수형으로 작성하지 않는다?

하지만, React Hook의 등장으로 함수형 컴포넌트로도 Container 컴포넌트의 역할이 가능해졌다.

🏓 소감

클래스형 컴포넌트와 함수형 컴포넌트에 차이에 대해 알게되었다.

프로젝트를 시작하게 될 때, 컴포넌트를 생성할텐데 이 때, 이 컴포넌트가 상태를 가지는 컴포넌트인지 상태를 갖지 않는 컴포넌트인지 구분을 짓고 컴포넌트를 생성해야겠다.

오늘날 리액트에서 React Hook이 등장하게 되면서 클래스형으로만 작성되던 stateful Component가 어떻게 함수형 컴포넌트로 바뀌게 되었는지를 배우게 되면 리액트에 대한 이해가 한층 더 깊어질 것 같다.

댓글 공유

📌 Mock Fn(모의함수)란?

테스트를 위한 함수로, 원하는 함수의 로직을 테스트 하려면 작성해야할 코드가 많지만 테스트 함수를 사용하여 원하는 테스트를 시험할 코드만 간략하게 작성하여 테스트해볼 수 있어 편리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
test("`onToggle` 속성(prop)에 연결된 함수가 정상적으로 실행됩니다.", () => {
let expected = "triggering toggle event";
let received = "";

render(
<ToggleButton
onToggle={() => {
received = expected;
}}
/>
);

expect(received).not.toBe(expected);

const element = screen.queryByRole("button");

fireEvent.click(element); // click button element

expect(received).toBe(expected);
});
  • 위 예제는 버튼이 클릭되었을 때, 해당 문구가 할당이 제대로 되는지 확인하는 테스트 코드이다.

테스트 목적은 버튼 클릭이 제대로 인식되는지, 선택된 요소가 예상된 값으로 할당이 되는지를 확인한다.

이렇게 테스트 코드를 작성하는 것은 번거로운 일이다.

그러므로 간략하게 테스트 함수를 생성하여 이벤트가 발생할 때, 모의 함수가 호출되면 앞서 말한 로직을 구현해줄 수 있으므로 버튼이 클릭되었을 때, 모의 함수 호출이 제대로 되는지만 확인하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
// mock Fn
test("`onToggle` 속성(prop)에 연결된 함수가 정상적으로 실행됩니다.", () => {
const mockFn = jest.fn();

render(<ToggleButton onToggle={mockFn} />);

const element = screen.getByRole("button");

fireEvent.click(element); // click button element

expect(mockFn).toHaveBeenCalled();
});

✏️ React VirtualDOM Test

리액트에서는 VitualDOM에 렌더링을 하여 RealDOM과 비교하기 때문에 Test시에도 RealDOM이 아닌 VirtualDOM에 렌더링된 것으로 테스트를 해줘야한다.

1
2
3
4
5
6
7
test("컴포넌트는 기본적으로 <span> 요소로 렌더링 됩니다.", () => {
render(<A11yHidden data-testid="tester" />);

const element = screen.getByTestId("tester");

expect(element.localName).toBe("span");
});
  • RealDOM에서 querySelector()로 요소를 가져오는 것처럼 VirtualDOM에서는 screen으로 요소를 가져올 수 있다.
  • 해당 요소를 가져오기 위해서 data-testId 값을 사용하였다.

❗️ localName vs nodeName 차이

  • localName : 소문자를 반환
  • nodeName : 대문자를 반환

defaultProps 사용하기

리액트 컴포넌트에서는 props에 default 값을 주기 위해서 defaultProps를 사용한다.

직접 파라미터에 App({ as: ComponentName = 'div' }) 이런식으로 주는 것을 안티패턴으로 본다.

1
2
3
4
5
6
7
8
9
10
11
import styles from "./A11yHidden.module.css";

export function A11yHidden({ as: ComponentName, ...restProps }) {
return (
<ComponentName className={styles.container} {...restProps}></ComponentName>
);
}

A11yHidden.defaultProps = {
as: "span",
};
  • as 라는 props를 받으면 해당하는 ComponentName으로 태그를 감싸서 컴포넌트를 생성한다.

component는 PascalCase 명명 규칙을 따르기 때문에 이를 따르지 않으면 에러를 발생시킨다.

getByRole vs getByTestId

기본적으로 div, span 같은 태그는 Role(역할)이 없다. 테스트 케이스에서 이러한 요소를 가져오기 위해서는 Role이 없으니 다른 방법을 찾아야한다.

그에 대한 대안으로 테스트용 data-testId="tester" 값을 주고 getByTestId()를 사용하여 요소를 가져올 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// div 요소 가져올 경우
test('컴포넌트의 `as` 속성(prop) 값이 "div"인 경우, <div> 요소로 렌더링 됩니다.', () => {
render(<Banner as="div" data-testid="tester" />);

const element = screen.getByTestId("tester");
expect(element.localName).toBe("div");
});

// figure 요소 가져올 경우
test("컴포넌트는 기본적으로 <figure> 요소로 렌더링 됩니다.", () => {
render(<Banner />);

const element = screen.getByRole("figure");
expect(element.localName).toBe("figure");
});

figure, img 등 우리가 직접 Role을 선정해주지 않아도 브라우저에서 해당 태그들을 해석할 때 Role을 할당해주기 때문에 이러한 요소들은 getByRole()를 이용하여 가져올 수 있다.

🏓 소감

마침 우테코에서도 Jest를 사용하고 있었는데, 수업시간 때 리액트 DOM 테스트를 해볼 수 있는 기회가 있어서 유익했다. 프론트 엔드에서는 사용자의 행동에 의해 UI가 변경되는 요소들이 많기 때문에 이러한 사용자 액션에 의한 test를 하기 어렵다.

이럴 경우 사용자가 행동하는 것처럼 테스트를 진행하는 storyBook이라는 testing Tool도 있으니 Jest가 익숙해지면 storyBook도 사용해봐야겠다.

댓글 공유

loco9939

author.bio


author.job