지금까지 프론트 엔드 공부를 하면서 선언적으로 코드를 작성하는게 뭔지 아예 모르고 있었다.
그냥 코드를 보기 편하게, 작성하기 편하게, 유지보수 하기 편하게 작성하고 있었다.
그런데 몇달전 읽었던 토스의 선언적인 코드 작성하기 를 읽어보니 그런 개념이 선언형 프로그래밍과 연결되는것 같았다.
예를들어 파이썬의 sum
함수가 선언형으로 작성된 함수 즉, 추상화가 높은 함수라 할 수 있다.
그걸 typescript 로 구현한다면 아래처럼 될것이다.
const sum = (numList: number[]) => {
let result = 0;
for (const num of nums) {
result += num;
}
return result;
}
const sumResult = sum([1,2,3,4]);
console.log(sumResult); // 10
그리고 for of 문도 사실 선언적인 코드라 볼 수 있다.
배열의 iterator 요소들을 순환하는 동작을 추상화 했기 때문이다.
그 내부는 이렇게 구현되어 있다.
const iterator = nums[Symbol.iterator]();
let step;
while (!(step = iterator.next()).done) {
const num = step;
/* 동작 ... */
}
이번에 검색 컴포넌트를 개발할 때 처음에는 오버레이로 띄울 생각으로 useOvelay 훅을 개발하려 했다.
그러다가 토스가 구현한 useOvelay
훅을 보고 Recoil을 사용해 만들어보자고 생각했다.
만약 useOvelay훅을 만들지 않고 오버레이를 구현한다면 아래처럼 구현이 모두 드러나게 구현했을 것이다.
예전에 최대한 이를 줄이고자 useModal을 아래처럼 구현했었다.
const { open: openModal, close: cloaseModal, ModalWrap } = useModal();
<button onClick={openModal}> 모달창 열기 버튼!! </button>
<ModalWrap>
<div> modal opened! </div>
<button onClick = {closeModal}> close </button>
</ModalWrap>
최대한 구현을 드러내고 싶지 않았는데 state 관리만 추상화 해준거에서 그쳤다.
그런데 이번에는 범용적으로 쓸 수있는 useOvelay 훅을 만들어 최대한 선언적으로 코드를 작성해보려 노력했다.
만약 예전에 useModal 훅을 수정해보라고 하면 아래처럼 수정했을 것이다.
const modal = useOvelay();
const handleClickButton = () => {
modal.open(({isOpen, close})=>(
<MyModal isOpen={isOpen} onClose={close}>
<div> modal opened! </div>
</MyModal>
));
}
<button onClick={handleClickButton}> 모달 열기 버튼!! </button>
확실히 구현이 모두 드러나지 않고 코드를 한눈에 읽기 쉬워진걸 알 수 있다.
구현할때는 toss slash github 가 정말 많은 도움이 되었다.
나는 Recoil로 구현을 할 것이기에 Atom부터 작성해줬다.
아톰은 별거 없다.
[key: number]: React.ReactNode로 이뤄진 Map을 가지고 있다.
// ovelayAtom.tsx
const ovelayAtom = atom<Map<number, React.ReactNode>>({
key: 'ovelayAtom',
default: new Map(),
});
export default ovelayAtom;
recoil로 상태를 만들고 관리 해줄때 아래처럼 커스텀 훅을 만들어서 관리를 해주고있따.
해당 훅에서 오버레이 추가, 삭제, 상태를 리턴 해주고 있다.
// useOvlayState.tsx
const useOvelayState = () => {
const [state, setState] = useRecoilState(ovelayAtom);
const addOvelay = (key: number, value: React.ReactNode) => {
setState(prev => {
const clone = new Map(prev);
clone.set(key, value);
return clone;
});
};
const deleteOvelay = (key: number) => {
setState(prev => {
const clone = new Map(prev);
clone.delete(key);
return clone;
});
};
return { ovelayState: state, addOvelay, deleteOvelay };
};
export default useOvelayState;
이 컴포넌트는 최상단에 위치하며 ovelayAtom의 상태로 ovelay들을 그려준다.
// OvelayRoot.tsx
const OvelayRoot = () => {
const ovelayState = useRecoilValue(ovelayAtom);
return (
<>
// ovelayAtom의 Value 를 순환하며 element를 그려준다.
{[...ovelayState.entries()].map(([id, element]) => (
<Fragment key={id}>{element}</Fragment>
))}
</>
);
};
export default OvelayRoot;
// useOvelay.tsx
import { useEffect, useRef } from 'react';
import useOvelayState from './useOvelayState';
export type OvelayElement = (props: { close: () => void }) => JSX.Element;
const useOvelay = () => {
const { addOvelay, deleteOvelay } = useOvelayState();
const id = useRef<number>(0);
useEffect(() => {
id.current++;
deleteOvelay(id.current);
}, []);
const handleClose = () => {
deleteOvelay(id.current);
};
return {
open: (OvelayElement: OvelayElement) => {
addOvelay(id.current, <OvelayElement close={handleClose} />);
},
close: () => {
deleteOvelay(id.current);
},
};
};
export default useOvelay;
useOvelay를 구현하면서 객체를 리턴할 때 open 부분이 이해가 안됐다.
이 훅을 사용할 때를 보면 아래처럼 isOpen, close를 내가 만든 NewOvelay에 전달 해준다.
ovelay.open(({isOpen, close})=>(
<NewOvelay isOpen={isOpen} onClose={close}>
<div> ovelay!! </div>
</NewOvelay>
));
근데 아래의 함수를 주입받는 부분이 이해가 안됐다.
내가 jsx를 잘 이해하고 있지 않았기 때문이다.
return{
open: (OvelayElement: OvelayElement) => {
addOvelay(id.current, <OvelayElement close={handleClose} />);
}};
jsx로 코드를 작성하면 createElement()가 실행된다.
그래서 jsx로 작성한 element는 아래와 같이 해석된다.
const element = (
<h1 className="elelel">
Hello, world!
</h1>
);
// 변환후
const element = React.createElement(
'h1',
{className: 'elelel'},
'Hello, world!'
);
그런데 아까 위의 OvelayElement 타입을 보면 props를 받아서
JSX.Element를 리턴하는 하나의 컴포넌트로 볼 수가 있다.
그러니까 저 부분이 js로 변환되면 아래와 같이 addOvelay에
OvelayElement의 실행 결과인JSX.Element가 인자로 넘어간다.
export type OvelayElement = (props: { close: () => void }) => JSX.Element;
return{
open: (OvelayElement: OvelayElement) => {
addOvelay(id.current, OvelayElement({close:{handleClose}));
}};
우선 앱의 최상단에 <OvelayRoot />
를 그려주었다.
그 후 아래와 같이 사용하면 된다.
참고로 isOpen은 필요하지 않을거라 생각했기에 넣어주지 않았고 close만 넣어줬다.
const myOvelay = useOvelay();
const handleClickButton = () => {
myOvelay.open(({close})=>(
<SearchOvelay onClose={close}/>
));
}
<button onClick={handleClickButton}> 모달 열기 버튼!! </button>
만약 오버레이가 열리기 전 후에 어떤 동작을 추가해준다면 Promise를 사용해서
오버레이 안에 해당 동작을 추가하지 않고도 간단히 해결할 수 있다.
const myOvelay = useOvelay();
const openMyOvelay = () => new Promise((resolve, reject)=>{
myOvelay.open(({close})=>(
<SearchOvelay
onClose={() => {
resolve(true);
close();
}}
/>
));
});
const handleClickButton = () => {
console.log("오버레이가 열리기 전");
await openMyOvelay();
console.log("오버레이가 ");
}
<button onClick={handleClickButton}> 모달 열기 버튼!! </button>
https://toss.tech/article/frontend-declarative-code
https://github.com/toss/slash/blob/main/packages/react/use-overlay/src/useOverlay.tsx
https://slash.page/ko/libraries/react/use-overlay/src/useoverlay.i18n/