합성 컴포넌트는 독립적으로 동작하지 않고 다른 컴포넌트와 같이 동작하도록 만들어진 컴포넌트이다.
예를들어 HTML 요소중 <select>와<option>은 개별적으로는 사용하지 않으며, 다음과 같이 사용된다.
<select>
<option>1</option>
<option>2</option>
<option>3</option>
</select>
이와 같이 항상 함께 작동해야 하는 컴포넌트를 합성 컴포넌트라 한다.
합성 컴포넌트를 사용하면 컴포넌트의 재사용성을 높일 수 있게된다.
여기서는 <Accordion>이라는 컴포넌트를 구현해 볼 것이다.
해당 컴포넌트는 한가지 요소를 열면 다른 요소는 닫히는 동작을 수행하는 컴포넌트이다.
interface AccordionProps {
className: string;
children: ReactNode;
}
interface AccordionContextType {
openItemId: string | null;
toggleItem: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
export function useAccordionContext() {
const ctx = useContext(AccordionContext);
if (!ctx) {
throw Error("Accordion-related components must be wrapped by <Accordion>.");
}
return ctx;
}
const Accordion = ({ children, className }: AccordionProps) => {
const [openItemId, setOpenItemId] = useState<string | null>(null);
const toggleItem = (id: string) => {
setOpenItemId((prevId) => (prevId === id ? null : id));
};
const AccordionValue = {
openItemId,
toggleItem
};
return (
<AccordionContext value={AccordionValue}>
<ul className={className}>{children}</ul>
</AccordionContext>
);
};
export default Accordion;
Accordion.Item = AccordionItem;
Accordion.Title = AccordionTitle;
Accordion.Content = AccordionContent;
<Accordion>컴포넌트는 Accordion의 여러 세부 컴포넌트를 감싸는 메인 컴포넌트이다.
이후에 구현할 Accordion.Title, Accordion.Item, Accordion.Content컴포넌트들은 모두 Accordion컴포넌트 내부에서 실행되어야 한다.
또한 하나의 Accordion.Item이 열리면 다른 Accordion.Item은 닫혀야하기 때문에 해당 상태를 변경할 수 있도록 context api를 사용한다.
해당 context는 useAccordionContext라는 훅을 통해 다른 파일에서 사용되며, 만약 다른 세부 컴포넌트들이 Accordion컴포넌트 내부에서 사용되지 않았을 경우 ctx가 null이 되어 오퓨를 발생시킨다.
Accordion.Item = AccordionItem;
Accordion.Title = AccordionTitle;
Accordion.Content = AccordionContent;
또한 위의 코드를 통해 컴포넌트호출을 <Accordion.Item>, <Accordion.Title>, <Accordion.Content>와 같이 사용할 수 있게한다.
interface AccordionItemProps {
id: string;
className: string;
children: ReactNode;
}
const AccordionItemContext = createContext<string | null>(null);
export function useAccordionItemContext() {
const ctx = useContext(AccordionItemContext);
if (!ctx) {
throw Error("AccordionItem-related components must be wrapper by <Accordion.Item>.");
}
return ctx;
}
const AccordionItem = ({ id, className, children }: AccordionItemProps) => {
useAccordionContext();
return (
<AccordionItemContext value={id}>
<li className={className}>{children}</li>
</AccordionItemContext>
);
};
export default AccordionItem;
Accordion.Item역시 Accordion컴포넌트와 유사하게 context api를 사용하고 있다.
여기서는 사용자에게 입력받은 id값을 Accordion.Title과 Accordion.Content로 전달하기 위해 사용중이며, Accordion.Title과 Accordion.Content가 Accordion.Item밖에서 사용될 경우 이전과 같은 오류를 발생시킨다.
Accordion.Item컴포넌트 내부에서 useAccordionContext()를 호출하는 이유는 Accordion.Item이 Accordion컴포넌트 밖에서 사용될 경우 오류를 발생시키려는 목적이다.
interface AccordionTitleProps {
className: string;
children: ReactNode;
}
const AccordionTitle = ({ className, children }: AccordionTitleProps) => {
const { toggleItem } = useAccordionContext();
const id = useAccordionItemContext();
return (
<h3 className={className} onClick={() => toggleItem(id)}>
{children}
</h3>
);
};
export default AccordionTitle;
해당 컴포넌트에서는 Title이 클릭될 경우 toggle을 해주는 함수를 AccordionContext로부터 받아와 사용하며, id값은 AccordionItemContext로부터 받아와 사용한다.
interface AccordionContentProps {
className: string;
children: ReactNode;
}
const AccordionContent = ({ className, children }: AccordionContentProps) => {
const { openItemId } = useAccordionContext();
const id = useAccordionItemContext();
const isOpen = openItemId === id;
return <div className={isOpen ? `${className} open` : `${className} close`}>{children}</div>;
};
export default AccordionContent;
해당 컴포넌트는 실체 콘텐츠를 담는 컴포넌트로 현재 open중인 id를 AccordionContext, 현재 콘텐츠의 id는 AccordionItemContext에서 받아와 사용한다.
open상태인 id와 현재 콘텐츠의 id가 같을 경우 open css를 적용한다.
<Accordion>
<Accordion.Item>
<Accordion.Title/>
<Accordion.Content/>
</Accordion.Item>
<Accordion.Item>
<Accordion.Title/>
<Accordion.Content/>
</Accordion.Item>
</Accordion>
Accordion합성 컴포넌트의 구조는 위와 같다.
Accordion.Item은 Accordion컴포넌트 내부에 존재해야하며, Accordion.Title과 Accordion.Content는 Accordion.Item내부에 존재해야 한다.
실사용 코드는 다음과 같다.
<Accordion className="accordion">
<Accordion.Item className="accordion-item" id="experience">
<Accordion.Title className="accordion-item-title">
We got 20 years of experience
</Accordion.Title>
<Accordion.Content className="accordion-item-content">
<article>
<p>You can't go wrong with us.</p>
<p>
We are in the business of planning highly individualized vacation trips for more
than 20 years.
</p>
</article>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item className="accordion-item" id="local-guides">
<Accordion.Title className="accordion-item-title">
We're working with local guides
</Accordion.Title>
<Accordion.Content className="accordion-item-content">
<article>
<p>We are not doing this along from our office.</p>
<p>
Instead, we are working with local guides to ensure a safe and pleasant
vacation.
</p>
</article>
</Accordion.Content>
</Accordion.Item>
</Accordion>
렌더 프롭은 컴포넌트의 props로 함수를 전달하여 UI를 동적으로 렌더링하는 방식으로, 재사용성을 높이는데 사용된다.
검색창이 있는 리스트 컴포넌트를 구현하려할 때 넘겨지는 데이터의 구조가 다른 경우가 존재한다.
예를들어 A리스트의 구조는 단순히 문자열 배열인 ["item1", "item2", "item3"]이고, B리스트의 구조는 다음과 같다 해보자.
[
{
id: "african-savanna",
image: savannaImg,
title: "African Savanna",
description: "Experience the beauty of nature."
},
{
id: "amazon-river",
image: amazonImg,
title: "Amazon River",
description: "Get to know the largest river in the world."
}
]
이러한 배열을 데이터로 가지며, 검색이 가능하도록 컴포넌트를 구현하려면 제네릭 타입을 사용하여 다음과 같이 구현할 수 있다.
import { ChangeEvent, useState } from "react";
interface SearchableListProps<T> {
items: T[];
}
const SearchableList = <T,>({ items }: SearchableListProps<T>) => {
const [searchTerm, setSearchTerm] = useState("");
const searchResults = items.filter((item) =>
JSON.stringify(item).toLowerCase().includes(searchTerm.toLowerCase())
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
return (
<div className="searchable-list">
<input type="search" placeholder="Search" onChange={handleChange} />
<ul>
{searchResults.map((item, index) => (
<li key={index}>{String(item)}</li>
))}
</ul>
</div>
);
};
export default SearchableList;
하지만 <li>를 사용하는 부분을 보면 item을 String으로 감쌓고, key값은 index값을 사용하고 있다.
이렇게 구현되는 이유는 T의 타입이 정의되지 않았기에 ReactNode타입과 호환이 안될 가능성이 존재하기 때문이다.
이런 경우 렌더 프롭을 통해 재사용성을 높이는것이 가능하다.
import { ChangeEvent, Key, ReactNode, useRef, useState } from "react";
interface SearchableListProps<T> {
items: T[];
children: (item: T) => ReactNode;
itemKeyFn: (item: T) => Key;
}
const SearchableList = <T,>({ items, itemKeyFn, children }: SearchableListProps<T>) => {
const [searchTerm, setSearchTerm] = useState("");
const searchResults = items.filter((item) =>
JSON.stringify(item).toLowerCase().includes(searchTerm.toLowerCase())
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
return (
<div className="searchable-list">
<input type="search" placeholder="Search" onChange={handleChange} />
<ul>
{searchResults.map((item) => (
<li key={itemKeyFn(item)}>{children(item)}</li>
))}
</ul>
</div>
);
};
export default SearchableList;
위의 코드에서 바뀐부분을 보면 children과 itemKeyFn가 props에 추가된 것을 확인할 수 있다.
또한 children의 type을 확인하면 ReactNode가 아니라 (item: T) => ReactNode;를 사용한 것을 확인할 수 있다.
이런식으로 구현하는경우 <SearchableList>컴포넌트를 사용할 때 다음과 같이 인자를 받아 사용할 수 있게되며, itemKeyFn도 비슷하게 동작한다.
<SearchableList items={PLACES} itemKeyFn={(item) => item.id}>
{(item) => <Place item={item} />}
</SearchableList>
<SearchableList items={["item1", "item2", "item3"]} itemKeyFn={(item) => item}>
{(item) => item}
</SearchableList>
children부분에서 함수를 사용할 수 있으며, 해당 함수는 내부에서 map을 통해 item을 전달해준다.
여기서 데이터 구조에따라 추가로 구현한 컴포넌트에 item을 전달해주어 사용할 수 있다.
key의 경우도 마찬가지로 함수를 통해 item을 전달받아 구조에 따라 설정하는것이 가능해진다.
디바운싱은 짧은 시간 동안 동일한 이벤트가 반복적으로 발생할 경우, 마지막 이벤트만 실행되도록 제어하는 기법으로 보통 사용자 입력 처리 최적화에서 사용된다.
예를들어 검색창에 텍스트를 입력할 때 매번 입력마다 검색을 진행하는것은 비효율적일 수 있기 때문에 디바운싱을 사용한다.
import { ChangeEvent, Key, ReactNode, useRef, useState } from "react";
interface SearchableListProps<T> {
items: T[];
children: (item: T) => ReactNode;
itemKeyFn: (item: T) => Key;
}
const SearchableList = <T,>({ items, itemKeyFn, children }: SearchableListProps<T>) => {
const [searchTerm, setSearchTerm] = useState("");
const laseChange = useRef<number>(null);
const searchResults = items.filter((item) =>
JSON.stringify(item).toLowerCase().includes(searchTerm.toLowerCase())
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (laseChange.current) {
clearTimeout(laseChange.current);
}
laseChange.current = setTimeout(() => {
laseChange.current = null;
setSearchTerm(e.target.value);
}, 500);
};
return (
<div className="searchable-list">
<input type="search" placeholder="Search" onChange={handleChange} />
<ul>
{searchResults.map((item) => (
<li key={itemKeyFn(item)}>{children(item)}</li>
))}
</ul>
</div>
);
};
export default SearchableList;
위의 코드는 디바운싱이 적용된 코드로, 해당 부분만 떼어내면 다음과 같다.
const lastChange = useRef<number>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (lastChange.current) {
clearTimeout(laseChange.current);
}
lastChange.current = setTimeout(() => {
lastChange.current = null;
setSearchTerm(e.target.value);
}, 500);
};
lastChange는 ref를 통해 컴포넌트가 리렌더링 되더라도 값을 유지하도록 해준다.
lastChange는 number값이나 null값이 들어갈 수 있으며, handleChange함수는 <input />과 연결되어 사용자가 입력할때마다 호출된다.
해당 함수 내부에서는 lastChange.current가 존재할 경우(이전에 한 번이라도 입력이 시작된 경우) clearTimeout을 통해 현재 동작중인 setTimeout을 초기화해준다.
그 다음 lastChange.current에 다시 새롭게 setTimeout을 호출하여 일정 시간 뒤에 함수가 호출되도록 구현한다.
만약 사용자가 입력을 시작할 경우 lastChange.current는 setTimeout을 통해 타이머가 시작되고, 500ms가 지난 후 내부 함수가 실행된다.
하지만 500ms가 지나기 전에 다시 입력을 할 경우 lastChange.current에서 이전 타이머는 제거되고 새로 500ms의 타이머가 담기게 된다.
따라서 마지막 입력이 되고 500ms후에 내부 로직이 실행되며, 이때 역시 lastChange.current를 null로 초기화하여 최초 입력 전 상태로 만들어준다.