확장성 있는 Text 컴포넌트

1
2
<Text typography="h1">나는 머릿말 1이야</Text>
<Text typography="content">나는 콘텐츠야</Text>

위와 같은 컴포넌트를 만들기 위한 작업을 시작해보자.

컴포넌트 재사용을 높이기 위해서는 누구나 이해하기 쉽게 작성하는 것이 좋다. 그러기 위해서는 선언형 코드인 HTML 방식을 따르는 것이 이득이다.

HTML 처럼 만든 컴포넌트

1
2
3
4
type Props = HTMLAttributes<"span">;
const Text = (props: Props) => {
/* ... */
};

이런식으로만 사용해도 HTML Element를 똑같이 따라할 수 있다.

하지만 HTML 속성은 ref, key 같은 리액트 속성들을 없다.

이 때 사용하는 것이 forwardRef 함수로 ref 값은 전달해준다.

1
2
3
4
5
6
const Text = forwardRef(function Text(
props: ComponentPropsWithoutRef<"span">,
ref: Ref<HTMLSpanElement>
) {
return <span ref={ref}>{props.children}</span>;
});
  • ComponentPropsWithoutRef<"span">은 ref만 제외한 나머지 리액트 프로퍼티를 모두 포함한다.

커스텀 프로퍼티 추가하기

지금까지의 컴포넌트는 그저 span element와 동일하다. 다른 커스텀 프로퍼티도 받을 수 있도록 타입스크립트의 & (intersection)을 사용하여 타입을 확장해보자.

1
2
3
4
5
6
7
8
9
10
type TextProps = {
typography?: string;
} & ComponentPropsWithoutRef<"span">;

const Text = forwardRef(function Text(
{ typography, ...props }: Props,
ref: Ref<HTMLSpanElement>
) {
//...
});

하지만, ComponentPropsWithoutRef<"span">가 이미 갖고 있는 프로퍼티와 커스텀 프로퍼티가 동일한 경우 대응하기 어렵다.

1
2
3
type TextProps = {
customId?: number;
} & ComponentPropsWithoutRef<"span">;

예를 들어 위와 같은 커스텀 프로퍼티는 ComponentPropsWithoutRef<"span">와 동일한 프로퍼티가 없기 때문에 제대로 타입이 추론된다.

1
2
3
type TextProps = {
id?: number;
} & ComponentPropsWithoutRef<"span">;

하지만 id 프로퍼티는 ComponentPropsWithoutRef<"span">가 소유하고 있어 동일한 프로퍼티 2개가 생기게 되므로 타입스크립트가 제대로 추론하지 못하고 undefined로 추론된다.

이를 해결하기 위해서는 Omit 타입을 사용하여 오버라이딩하려는 프로퍼티를 먼저 제거한 후 병합해야지만 문제가 없다.

1
type Combine<T, K> = T & Omit<K, keyof T>;
  • Combine 유틸타입은 2개의 타입을 받은 후 K타입에서 T 타입이 가진 프로퍼티와 중복되는 프로퍼티를 제거한 후 & (intersection)으로 병합한다.

이런식으로 타입을 병합하려면 상당히 귀찮기 때문에 유틸 타입을 하나 만들어둔다.

원하는 요소로 렌더링 하기

지금까지는 span 요소로만 작동하기 때문에 확장성을 위해 ComponentPropsWithoutRef<"span"> 에서 span 위치에 변수를 할당하도록 구현한다.

1
2
3
4
5
6
7
8
9
// Text 컴포넌트의 커스텀 프로퍼티 선언
type TextBaseProps<T> = {
typography?: string;
as?: T;
};

// Props<T>는 ComponentPropsWithoutRef<T>에 이 값을 그대로 넘겨준다.
// 그리고 커스텀 프로퍼티 내부의 as에도 T 타입을 바인딩해준다.
type TextProps<T> = Combine<TextBaseProps<T>, ComponentPropsWithoutRef<T>>;

하지만, 이는 오류를 발생시킨다. ComponentPropsWithoutRef가 받을 수 있는 제네릭 타입이 ElementType으로 정해져 있기 때문이다.

그래서 type TElementType을 상속한 타입이여야 한다고 명시해야한다.

1
2
3
4
5
6
7
8
9
10
11
type TextBaseProps<T extends ElementType> = {
typography?: string;
as?: T;
};

type TextProps<T extends ElementType> = Combine<
TextBaseProps<T>,
ComponentPropsWithoutRef<T>
>;

function Text<T extends ElementType>(props: TextProps<T>) {}

이제 모든 type T가 동일하다는 것을 보장할 수 있다.

  1. Text<T>의 타입 변수 T
  2. TextProps<T>의 타입 변수 T
  3. TextBaseProps<T>의 타입 변수 T
  4. as 프로퍼티에 바인딩 된 타입 변수 T
  5. ComponentPropsWithoutRef<T>의 타입 변수 T

즉, 이 중 한곳이라도 T에 대해서 명확하게 알 수 있다면 나머지 부분에서도 자연스럽게 추론이 가능하다.

as 프로퍼티로 타이핑 추상화 하기

as라는 프로퍼티는 Text 컴포넌트 뿐만 아니라 다양한 컴포넌트에서도 사용될 수 있기에 이 부분을 최대한 추상화 해둘 필요가 있다.

1
2
3
4
5
6
7
8
// 텍스트 컴포넌트의 프로퍼티
type TextBaseProps = {
typography?: string;
};

// T 타입을 추론할 수 있는 as 프로퍼티를 자동으로 포함하고
// T 타입으로 HTML 엘리먼트 속성까지 타이핑 해주는 OverridableProps!
type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;
  • OverridableProps 타입은 특정 컴포넌트가 as 프로퍼티를 사용하여 HTML 요소 이름을 받고 내부적으로 해당 요소의 속성 타입을 찾아 바인딩해주는 함수이다.

이렇게 필요한 부분을 추상화해두면 필자가 아닌 다른 개발자는 ComponentPropsWithoutRef을 사용해야한다던가 Combine 타입을 사용할 때 타입 변수 TElementType으로 제한해야한다던가 하는 귀찮은 부분을 생각하지 않고도 as 프로퍼티를 쉽고 빠르게 추가할 수 있을 것이다.

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
// 전체 코드
export type Combine<T, K> = T & Omit<K, keyof T>;

export type CombineElementProps<T extends ElementType, K = unknown> = Combine<
K,
ComponentPropsWithoutRef<T>
>;

type OverridableProps<T extends ElementType, K = unknown> = {
as?: T;
} & CombineElementProps<T, K>;

type TextBaseProps = {
typography?: string;
};

type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;

function Text<T extends ElementType = "span">(
{ typography = "content", as, ...props }: TextProps<T>,
ref: Ref<any>
) {
const target = as ?? "span";
const Component = target;

return (
<Component
ref={ref}
// 대충 타이포그래피 클래스 렌더하는 로직
{...props}
/>
);
}

export default forwardRef(Text) as typeof Text;

참조

Evans Library Blog - “타입스크립트와 함께 컴포넌트 단계별로 추상화하기”

댓글 공유

sticky 잘 사용하기

카테고리 Daily

sticky 잘 알고 사용하기

position:stickyposition:static 처럼 일반적인 흐름을 따르지만, 일정 스크롤을 내렸을 때 해당 임계점에 도달하였을 때, position:fixed 처럼 구현하기 위한 속성이다.

1
2
3
4
5
6
.sticky {
position: -webkit-sticky; /* 사파리 브라우저 지원 */
position: sticky;
top: 4px;
background: red;
}
  • sticky 속성을 설정하면 top, bottom, right, left 속성 중 하나는 반드시 설정해줘야한다.

또한 sticky가 기준으로 하는 임계점은 overflow:auto, overflow:scroll 속성을 가진 가장 가까운 조상 컨테이너 박스이다.

예시

1
2
3
4
5
6
7
8
9
10
11
<div class="scroll">
<div class="parent">
<b class="static">.static</b>
<b class="sticky">.sticky</b>
<b class="fixed">.fixed</b>
</div>
<div class="parent">
<b class="static">.static</b>
<b class="sticky">.sticky</b>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.scroll {
display: inline-block;
width: 200px;
height: 400px;
overflow: auto;
padding: 0 4px;
vertical-align: middle;
border: 4px solid #000;
}
.parent {
height: 300px;
margin: 4px 0 200px;
border: 4px solid #666;
}
.static {
position: static;
background: gray;
}
  • 위 코드에서 sticky가 fixed로 고정되는 임계기준점은 scroll 클래스를 가진 div 박스이다.
  • scroll 클래스가 있는 박스는 스크롤이 할 수 있을 만큼 길이가 길어서 스크롤 하여 해당 임계점에 도달하는 순간 fixed로 변하게 된다.

처음에 헷갈린 점은 overflow:auto 속성을 parent 클래스를 가진 박스로 옮겼는데 아무런 일도 일어나지 않아서 당황했다. 그 이유는 parent 클래스를 가진 박스 내부가 스크롤이 되었을 때 해당 박스를 기준으로 고정되는 것인데, parent 클래스를 가진 박스는 스크롤이 될만한 높이를 가지고 있지 않아서 아무런 일도 발생하지 않았던 것이다.

댓글 공유

이번에 프로젝트를 진행하다가 아이콘을 사용하기 위해 svg 파일을 css background:url()로 불러와서 사용한 적이 있다.

그런데 로컬환경에서는 제대로 불러와지던 svg 파일들이 build를 하고 난 후, 확인해보니 불러와지지 않는 것을 확인하였다.

그래서 찾아보니 Vite에서는 다음 asset을 가져오기 위해 2가지 방법을 사용한다고 한다.

Vite에서 assets 가져오기

1. URL을 통해 가져오기

1
2
import imgUrl from "./img.png";
document.getElementById("hero-img").src = imgUrl;
  • Webpack의 file-loader와 비슷한데, 차이점이 있다면 Vite는 절대경로와 상대 경로 둘 다 사용가능하다.
  • 일반적인 이미지, 미디어, 폰트 파일 타입은 자동으로 asset 목록에 포함된다.

2. public 디렉터리에서 가져오기

  • robots.txt 같이 소스 코드에서 참조하지 않는 asset
  • 해싱 없이 항상 같은 이름을 갖는 asset
  • URL을 얻기 위해 굳이 import 할 필요가 없는 asset

위 3가지의 경우에는 public 디렉터리에 asset을 위치시킨다. 이곳에 위치한 asset은 개발 시 / 경로에, 배포시에는 dist 디렉터리에 위치한다.

1
2
3
.arrowDown {
background-image: url("/assets/img/Arrow.svg");
}
  • public 디렉터리에 있는 asset을 가져올 경우에는 항상 루트 기준으로 하는 절대경로에서 가져와야한다. (public/Arrow.svg asset은 소스 코드에서는 /Arrow.svg으로 접근 가능하다.)
  • public 디렉터리에 있는 asset은 JavaScript로 가져올 수 없다.

가급적 import를 사용하자.

React Router를 사용하여 중첩 라우팅을 구현하였다.

/mypage/history 라우팅에서 Header 컴포넌트를 불러왔다.

Header 컴포넌트에는 public 폴더에서 불러온 logo가 있다.

하지만 이 때의 경로는 /img/logo.svg 였기 때문에 /mypage/history 라우팅에서는 해당 경로의 파일을 찾을 수 없다.

해당 경로의 파일을 찾기 위해서는 /mypage/history/img/logo.svg 로 경로를 바꿔줘야 한다.

즉, public 폴더에서 이미지를 가져올 경우, 라우팅이 바뀔 때마다 경로를 생각해줘야한다.

그러므로 import 구문을 사용하여 src 폴더 안에 이미지를 가져오도록 하자.

댓글 공유

Recoil에 들어가기 전...

카테고리 Daily

상태란?

상태(state)는 애플리케이션의 작동 방식을 설명하는 모든 데이터를 말한다. 상태 관리는 시간의 흐름에 따라 상태가 변경되는 방식이다.

상태 관리를 위해서는 다음 기능이 필요하다!

  • 초기값(initial Value)을 저장할 수 있어야 한다.
  • 현재 값(Current Value)을 읽을 수 있어야 한다.
  • 값을 업데이트 할 수 있어야 한다.

React 상태관리의 한계

  1. 컴포넌트 상태는 연관된 상위 컴포넌트까지 끌어올려야 공유가 가능하고 이 과정에서 Props Drilling 이슈가 발생하고 불필요한 리렌더링 발생한다. (성능저하)

  2. Context로 Props Drilling 이슈 해결할 수 있지만, Context는 단일 값만 저장할 수 있고 여러 값들의 집합을 담기가 어렵다.

  3. React의 Context, Props 만으로는 최상단에서 최하단 까지의 state 코드 분리가 어렵다.

Recoil 장점

유연한 상태 공유

Redux처럼 복잡한 과정이 없다. 간단한 get/set 인터페이스를 사용해 상태 공유가 쉽다.

파생된 상태

데이터는 간단하고 또 안전하게 상태나 다른 파생된 데이터로부터 파생될 수 있다. 또한, 상태에 접근하는 방법과 동일하게 파생된 상태에 접근 가능하다.

파생된 상태는 동기, 비동기 처리가 가능하다.

광범위한 앱 상태 관찰

Recoil 상태의 전체 또는 일부의 상태를 읽거나 상태가 변경된 것을 감지할 수 있다. 앱의 상태를 유지할 수도 있고 다시 수화(Hydration)하는 기능을 제공한다.

비교적 낮은 러닝 커브

Recoil은 React API와 유사하여 Redux에 비해 쉽고 Recoil은 기본적으로 비동기 처리 기능을 가진다.

Recoil 핵심 개념

1. atoms

  • atoms은 공유 상태
  • 컴포넌트가 구독할 수 있는 상태 단위

atom은 상태단위이며 구독과 업데이트가 가능하다. atom이 업데이트되면 구독중인 컴포넌트는 새 값을 반영해 다시 렌더링된다.

1
2
3
4
const authUserState = atom({
key: "authUserState",
defualt: null,
});
  • atom은 고유한 key를 가져야 한다.
  • default 속성으로 초깃값 설정한다.

컴포넌트에서 atom을 읽거나 쓰려면 useRecoilState 훅을 사용한다.

1
2
3
4
5
6
7
function SignIn() {
const [authUser] = useRecoilState(authUserState);

return (
!authUser ? <SignInForm /> : <Browse />;
);
}

뿐만 아니라 상태 업데이트를 위한 업데이트 함수만 추출할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SignInButton() {
const [, setAuthUser] = useRecoilState(authUserState);

const handleSignIn = () => {
// ...
setAuthUser({ name, email });
};

return (
<button type="button" onClick={handleSignIn}>
로그인
</button>
);
}

2. selectors

selector는 atom이나 다른 selector를 입력받아 파생된 상태를 생성하는 순수함수이다. 상위의 atom, selector가 업데이트되면 하위의 selector도 다시 실행된다. 컴포넌트는 atom과 마찬가지로 selector가 변경되면 다시 렌더링된다.

최소한의 상태만 atom에 저장하고 selector로 파생된 상태를 효과적으로 계산하여 불필요한 상태 보존을 방지한다.

selector는 어떤 컴포넌트가 필요로하는지, 어떤 상태에 의존하는지 추적하므로 함수적인 접근 방식을 매우 효율적으로 만든다.

1
2
3
4
5
6
7
const authUserNameState = selector({
key: "authUserNameState",
get:({ get }) {
const authUser = get(authUserState);
return authUser.name;
},
});
  • get 메서드 내부의 get함수는 atom 또는 다른 selector를 전달받을 수 있다.
  • 전달받게 되면 자동적으로 종속 관계가 생성되며 참조했던 다른 atom, selector가 업데이트 되면 다시 실행된다.

댓글 공유

createPortal 사용하여 접근성 높인 모달 구현하기

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
// Modal.tsx
import { useEffect } from "react";
import { createPortal } from "react-dom";

function Modal({ onClick }: { onClick: () => void }) {
const handleModalClose = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
onClick();
};
useEffect(() => {
document.body.style.cssText = `position: fixed; top: -${window.scrollY}px`;
return () => {
const scrollY = document.body.style.top;
document.body.style.cssText = `position: ""; top: "";`;
window.scrollTo(0, parseInt(scrollY || "0") * -1);
};
}, []);

useEffect(() => {
document.addEventListener("keyup", handleModalClose);
return () => document.removeEventListener("keyup", handleModalClose);
});
return createPortal(
<div>
<article role="dialog" aria-modal="true" className="Dialog">
<header className="Dialog__header">
<h2>React 포털로 연 다이얼로그(with 모달)</h2>
</header>
<div className="Dialog__body">
<p>여기가 React 앱 밖의 세상인가요?!</p>
</div>
<footer className="Dialog__footer">
<button
type="button"
className="closeButton"
aria-label="모달 다이얼로그 닫기"
title="모달 다이얼로그 닫기"
onClick={onClick}
>
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
>
<path d="M12 11.293l10.293-10.293.707.707-10.293 10.293 10.293 10.293-.707.707-10.293-10.293-10.293 10.293-.707-.707 10.293-10.293-10.293-10.293.707-.707 10.293 10.293z" />
</svg>
</button>
</footer>
</article>
<div className="Dialog__dim" onClick={onClick}></div>
</div>,
document.getElementById("modal") as HTMLElement
);
}

export default Modal;
  • createPortal로 root 태그가 아닌 새로운 id가 modal인 태그에 modal을 생성하였다.
  • Esc 키를 눌러 모달을 끌 수 있게 하였다.
  • 모달이 열려있을 때, 모달 뒤의 배경이 스크롤되지 않도록 하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// App.tsx
import { useState } from "react";
import "./App.css";
import Modal from "./Modal";
import ModalBtn from "./ModalBtn";

function App() {
const [modalOpen, setModalOpen] = useState(false);

const handleModalOpen = () => setModalOpen(true);
const handleModalClose = () => setModalOpen(false);
return (
<div className="App">
<ModalBtn onClick={handleModalOpen} modalOpen={modalOpen} />
{modalOpen && <Modal onClick={handleModalClose} />}
</div>
);
}

export default App;
  • ModalBtn을 클릭하여 모달을 열 수 있게한다.
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
// ModalBtn.tsx
function ModalBtn({
onClick,
modalOpen,
}: {
onClick: () => void;
modalOpen: boolean;
}) {
return (
<div className="box">
<button
type="button"
className="openDialogButton"
aria-haspopup="dialog"
aria-label="모달 다이얼로그 열기"
title="모달 다이얼로그 열기"
onClick={onClick}
tabIndex={modalOpen ? -1 : 0}
>
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
>
<path d="M14 4h-13v18h20v-11h1v12h-22v-20h14v1zm10 5h-1v-6.293l-11.646 11.647-.708-.708 11.647-11.646h-6.293v-1h8v8z" />
</svg>
</button>
</div>
);
}

export default ModalBtn;
  • ModalBtn에 모달 상태를 전달하여 모달이 열려있다면 모달 배경에 포커스가 가지 않도록 하였다.

배경 요소에 모든 focus 요소에 tabIndex 속성을 설정해줘야하는 번거로움이 있다.

댓글 공유

1
2
3
4
5
6
7
8
9
10
11
import styled from "@emotion/styled";

const StyledButton = styled.button`
width: 100px;
height: 50px;
color: ${(props) => (props.isClicked ? "green" : "white")};
`;

function Btn() {
return <StyledButton isClicked>선택하기</StyledButton>;
}
  • 위와 같이 사용하게 되면 StyledButton 에 커스텀 props를 타입 선언을 해주지 않았기 때문에 에러를 발생한다.

이를 해결하기 위해 커스텀 props 타입을 단언해주면된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import styled from "@emotion/styled";

const StyledButton = styled.button<{ isClicked: boolean }>`
width: 100px;
height: 50px;
color: ${(props) => (props.isClicked ? "green" : "white")};
`;

function Btn({ isClicked }: { isClicked: boolean }) {
return (
<StyledButton isClicked={isClicked}>선택하기</StyledButton>
);
}

댓글 공유

image sprite 실전 사용기

카테고리 Daily

image sprite 실전 사용기

기존 프로젝트에서는 돋보기 아이콘을 불러올 때, png 파일을 통해서 각각 한개씩 불러왔다.

이렇게 되면 돋보기 이미지는 상태에 따라 default, focus, disabled 총 3가지 상태를 가진 이미지가 있기 때문에 이미지를 3번 불러와야한다.

하지만 이미지 스프라이트를 사용하면, image sprite를 사용하여 3가지 상태의 이미지를 svg 파일 한 곳에 넣어두고 이를 배경이미지로 불러와서 원하는 위치의 이미지에 position을 주어 보여주도록 하였다.

1
2
3
4
5
img {
width: 46px;
height: 44px;
background: url("img_navSprites.png") 0 0;
}
  • 이미지를 배경으로 불러온 뒤 left, top 속성으로 배치한다.
1
2
3
4
5
6
7
function App() {
return (
<div>
<img src="img_trans.png" >
</div>
)
}
  • src 속성을 비워두게 되면 img 태그에 테두리가 생기게 되는데, 이는 제거하려해도 제거할 수가 없다. (border, outline, padding, margin 여러 방법을 써봐도 안됐다.)

그래서 src 속성을 비워두지 않기 위해 투명한 이미지 경로를 넣어준 것이다.

1
2
3
.next {
background: url("img_navSprites.png");
}
1
2
3
4
5
6
7
8
// 또는 img 태그 대신 i 태그 사용하기
function App() {
return (
<div>
<i className="next">
</div>
)
}

오늘 프로젝트를 진행하면서 icon을 불러오는 용도로 사용했기 때문에 img 태그 대신 i 태그를 사용하여 불러오니 src 속성없이도 불러올 수 있어서 편리하였다.

댓글 공유

리액트에서 이미지 넣기

1. import 구문 사용하기

1
import Logo from "./assets/img/Logo.png";

컴포넌트에서 import 구문을 사용하여 이미지를 불러올 수 있다.

이렇게 불러온 이미지는 img 태그의 src 속성에 할당한다.

1
2
3
4
5
6
7
function App() {
return (
<div>
<img src={Logo} alt="Logo">
</div>
)
}

단, import는 React가 처리하지 않는다는 것을 명심해야한다. 이는 Webpack이나 vite같은 번들러들에 의해 처리된다.

2. public 폴더에 이미지 불러오기

1
2
3
public
|__images
|__logo.png

다음과 같이 public 폴더에 images를 담아두고 public 폴더를 절대경로로 이미지를 불러올 수 있다.

1
2
3
4
5
6
7
function App() {
return (
<div>
<img src="images/logo.png" alt="Logo">
</div>
)
}

댓글 공유

데이터와 디자인을 컴포넌트로 구성하기

1
2
3
4
5
6
7
8
[
{ "category": "과일", "price": 1920, "stocked": true, "name": "사과" },
{ "category": "과일", "price": 1640, "stocked": true, "name": "두리안" },
{ "category": "과일", "price": 2480, "stocked": false, "name": "아보카도" },
{ "category": "채소", "price": 1700, "stocked": true, "name": "시금치" },
{ "category": "채소", "price": 2800, "stocked": false, "name": "호박" },
{ "category": "채소", "price": 1330, "stocked": true, "name": "완도콩" }
]

design

위와 같이 데이터와 디자인 시안이 제시되었을 때, 컴포넌트를 어떻게 구성하는 것이 좋을지 생각해본다.

UI와 데이터는 주로 동일한 모양을 갖기 때문에 데이터 구조와 컴포넌트 구조가 자연스럽게 매핑된다.

그러므로 다음과 같이 컴포넌트를 구성할 수 있을 것이다.

component

1
2
3
4
5
FilterableProductTable
├── SearchBar
└── ProductTable
├── ProductCategoryRow
└── ProductRow

폴더 구조는 위와 같이 구성할 수 있을 것이다.

이상적인 컴포넌트 설계는 한가지만 책임하는 것이다. 만약 한가지 이상의 처리가 필요하다면 컴포넌트를 더 작은 컴포넌트로 나눠야한다.

React 정적 컴포넌트 구성

컴포넌트에 대한 구상이 끝났으므로 React로 앱을 빌드해보자.

이 때 가장 간단한 접근법은 사용자와의 상호작용은 고려하지 않고 데이터 모델에서 UI를 렌더링 하는 버전으로 작성하는 것이다.

데이터 모델을 렌더링하는 앱을 만들고자 한다면 props와 state를 사용하고 싶을 것이다. 하지만 현재 단계에서는 state를 사용하지 않는다.
왜냐하면 state는 사용자와 상호작용할 때 사용하기 때문에 아직 단계가 아니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import PRODUCTS from "../../api/products.json";

function FilterableProductTable() {
const products = [...PRODUCTS];

return (
<div className="filterableProductTable">
<SearchBar />
<ProductTable products={products} />
</div>
);
}

export default FilterableProductTable;

정적 빌드된 컴포넌트는 JSX만을 반환한다. 그리고 컴포넌트 트리 최상위 컴포넌트에서 데이터 모델을 하위 props로 전달하는 “단방향 데이터 흐름”을 구현한다.

컴포넌트 UI 대화형으로 만들기

UI를 대화형으로 만들려면 사용자가 기본 데이터를 변경할 수 있도록 해야한다. 이러한 상호작용이 필요할 때, state를 사용한다.

state는 앱이 기억하고 변경해야 할 데이터의 최소 집합이라고 생각할 수 있다.

이 때 상태를 구성하는 가장 중요한 원칙은 DRY(Don’t Repeat Yourself)이다. state는 최소한으로만 필요해야하며 나머지는 필요할 때 다시 고민한다.

그렇다면 위 컴포넌트에서는 state는 무엇이 될 수 있을까?

state와 props 구분하기

1. 시간이 지남에도 변경이 없다면?

=> 이는 state가 아니다. state는 사용자와 상호작용해야하는 요소이기 때문이다.

2. props를 통해 상위 컴포넌트로 부터 전달될 수 있다면?

=> 이는 state가 아니다.

3. 컴포넌트의 기존 state 또는 props를 기반으로 계산할 수 있다면?

=> 이는 state가 아니다.

그러므로 위 예시에서 state는 검색 텍스트 값과 체크박스 값이 될 수 있다.

앱 상태 관리 하기

자 이제 앱이 가지는 최소한의 state를 파악했다. 그러면 이 state를 변경하고 소유하는 컴포넌트는 누가 할 것인지를 정해줘야한다.

React는 단방향 데이터 흐름을 사용하기 때문에 상위 컴포넌트에서 하위 컴포넌트로 데이터가 전달된다.

컴포넌트 상태 관리 전략

  1. state를 기반으로 무언가를 렌더링하는 모든 컴포넌트 확인한다.
  2. 해당 컴포넌트와 가장 가까운 공통 상위 컴포넌트를 찾는다.

이제 state를 어디서 관리할지 정해줘야한다. 경우의 수는 3가지가 있다.

  • state를 공통 상위 컴포넌트에서 관리
  • state를 공통 상위 컴포넌트보다 상위에서 관리
  • state를 소유하는 것이 타당한 컴포넌트를 찾지 못한 경우, state를 유지하기 위한 새로운 컴포넌트 만들고 공통 상위 컴포넌트 보다 위 어딘가에 추가하여 관리

위 예시에서 검색 텍스트 값과 체크박스 값이 state라고 하였다.

컴포넌트 상태 관리 단계

  1. 상태를 사용하는 컴포넌트 식별
  • SearchBar - 검색 텍스트 state 관리
  • ProductTable - 체크박스 유무 state 관리
  1. 공통 상위 컴포넌트 찾기

SearchBar, ProductTable 의 공통 상위 컴포넌트는 FilterableProductTable 컴포넌트이다.

  1. state가 있는 위치 결정

공통 상위 컴포넌트인 FilterableProductTable 컴포넌트에서 관리한다.

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 PRODUCTS from "../../api/products.json";
import SearchBar from "./SearchBar/SearchBar";
import ProductTable from "./ProductTable/ProductTable";
import { useState } from "react";

function FilterableProductTable() {
const products = [...PRODUCTS];

const [searchText, setSearchText] = useState("");
const [inStockOnly, setInStockOnly] = useState(false);

const handeChangeSearchText = (newSearchText: string) =>
setSearchText(newSearchText);

const handeChangeInStockOnly = (inStockOnlyState: boolean) =>
setInStockOnly(inStockOnlyState);

return (
<div className="filterableProductTable">
<SearchBar
searchText={searchText}
inStockOnly={inStockOnly}
onChangeSearchText={handeChangeSearchText}
onChangeInStockOnly={handeChangeInStockOnly}
/>
<ProductTable
products={products}
searchText={searchText}
inStockOnly={inStockOnly}
/>
</div>
);
}

export default FilterableProductTable;

해당 컴포넌트에서 state를 관리하고 props로 전달하는 것은 완료했으니 state를 관리하는 컴포넌트에서 state 변경함수까지 props 전달하여 사용자가 UI를 업데이트 할 수 있도록 구현해주면 완성이다.

댓글 공유

Accordion

default

active

1. HTML 마크업 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<button class="accordion">Section 1</button>
<div class="panel">
<p>Lorem ipsum...</p>
</div>

<button class="accordion">Section 2</button>
<div class="panel">
<p>Lorem ipsum...</p>
</div>

<button class="accordion">Section 3</button>
<div class="panel">
<p>Lorem ipsum...</p>
</div>

2. CSS 스타일링

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
.accordion {
background-color: #eee;
color: #444;
cursor: pointer;
padding: 18px;
width: 100%;
text-align: left;
border: none;
outline: none;
}

/* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */
.accordion:hover {
background-color: #ccc;
}

/* Style the accordion panel. Note: hidden by default */
.panel {
padding: 0 18px;
background-color: white;
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease-out;
}

.active {
max-height: 100vh;
}

3. React 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Accordion.jsx
import { useState } from "react";
import "./styles/main.css";

function Accordion({ title, info }) {
const [showInfo, setShowInfo] = useState(false);
return (
<>
<button className="accordion" onClick={() => setShowInfo(!showInfo)}>
{title}
</button>
<div className={`panel ${showInfo ? "active" : ""}`}>
<p>{info}</p>
</div>
</>
);
}

export default Accordion;

댓글 공유

loco9939

author.bio


author.job