티스토리 뷰
Introduction
개발초기 빠른 메이킹을 위해서 UI 라이브러리 중 MUI를 주로 사용하였었습니다. 그러다 보니 컴포넌트를 만드는 실력은 크게 늘지 않았습니다. 대신 자연스럽게 다른 방법을 찾게 되었습니다.
MUI 컴포넌트들은 어떤 방식의 구조와 코드로 만들어진걸까?
그렇게 작업하면서 틈이 생기면 가끔 들여다보곤 했습니다. 이번에는 새로운 프로젝트를 진행하면서 MUI에서 제공하는 기능은 챙기면서 커스텀이 필요하다고 판단되어 최근 MUI에서 제공하는 Input을 커스텀하여 Input Component를 만들어보게 되었습니다.
빠르게 최종적인 코드는 아래와 같고, 이번 작업을 하면서 배워간 요소들을 작게나마 풀어보려 합니다.
import { Box, Input as MUIInput, styled, Typography } from "@mui/material";
import { forwardRef, useId, useRef, useState } from "react";
import { InputBaseProps } from "./type";
const INPUT_HEIGHT_LIST = {
"40": "40px",
"44": "44px",
};
/**
* @example
<Input
size="40"
label="아이디"
required
placeholder="아이디를 입력해주세요."
onChange={handleChange}
error={error}
errorMessage="8글자 이상 입력해주세요."
value={value}
ref={ref}
endAdornment={[
<Button size="2xs" variant="default">
인증
</Button>,
]} // 배열 가능
/>
*/
const Input = forwardRef(function Input(props: InputBaseProps, ref: React.ForwardedRef<Element>) {
const { size = 40, label, value, onChange, id: propId, required, error, errorMessage, endAdornment } = props;
const [isFocus, setIsFocus] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const generatedId = useId();
const id = propId || generatedId;
const handleClear = () => {
if (onChange) {
onChange({ target: { value: "" } });
inputRef.current.focus();
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
};
return (
<S.InputContainer>
<S.TopWrapper>
{label && (
<S.Label variant="caption.100" isFocus={isFocus} error={error}>
<label htmlFor={id} style={{ cursor: "pointer" }}>
{label}
</label>
</S.Label>
)}
{required && label && <S.Required variant="caption.100">*</S.Required>}
</S.TopWrapper>
<S.InputBaseWrapper>
<S.InputBase
{...props}
ref={ref}
inputRef={inputRef}
id={id}
error={error}
disableUnderline
autoComplete="off"
onChange={handleChange}
onFocus={() => setIsFocus(true)}
onBlur={() => setIsFocus(false)}
isFocus={isFocus}
height={INPUT_HEIGHT_LIST[size]}
endAdornment={
<S.EndAdornmentWrapper>
{value && <Box onClick={handleClear}>x</Box>}
{endAdornment}
</S.EndAdornmentWrapper>
}
/>
</S.InputBaseWrapper>
{errorMessage && error && (
<S.ErrorMessage variant="caption.100" color="system_error">
{errorMessage}
</S.ErrorMessage>
)}
</S.InputContainer>
);
});
const S = {
InputContainer: styled(Box)(() => ({
display: "flex",
flexDirection: "column",
})),
TopWrapper: styled(Box)(() => ({
display: "flex",
flexDirection: "row",
})),
ErrorMessage: styled(Typography)(() => ({
margin: "4px 0px 0px 4px",
})),
Label: styled(Typography)<any>(({ theme, isFocus, error }) => ({
margin: "0px 0px 8px 4px",
color: error ? "red" : isFocus ? theme.palette.primary_main : null,
transition: "color 0.3s",
cursor: "pointer",
})),
Required: styled(Typography)(({ theme }: any) => ({
margin: "0px 0px 8px 4px",
color: theme.palette.system_error,
})),
InputBase: styled(MUIInput)<any>(({ theme, isFocus, error, height }) => ({
display: "flex",
flexDirection: "row",
background: theme.palette.background.default2,
borderRadius: "8px",
height: height,
padding: "11px 16px 11px 16px",
color: theme.palette.background.text_10,
...theme.typography["body.200"],
border: `1px solid ${error ? "red" : isFocus ? theme.palette.primary_main : "transparent"}`,
transition: "border 0.3s",
})),
InputBaseWrapper: styled(Box)(() => ({
position: "relative",
})),
EndAdornmentWrapper: styled(Box)(() => ({
display: "flex",
alignItems: "center",
columnGap: "13px",
})),
};
export default Input;
forwardRef
우선 기본적으로 React에서 ref prop은 HTML Element에 직접 접근하기 위해서 사용됩니다. 작업을 하다 보면 부모 요소의 컴포넌트에서 자식요소의 엘리먼트에 접근을 해야 할 경우가 있습니다. 또 컴포넌트에서 ref prop을 전달하기 위해서는 forwardRef()라는 함수를 사용해야 합니다. 컴포넌트를 forwardRef() 함수로 감싸주면, 2번째 매개변수를 통해 ref prop을 넘길 수 있습니다.
_components/Input
page
동작
디버깅
forwardRef() 함수를 호출할 때 익명 함수를 넘기면 브라우저에서 React Developer Tools를 사용하는 경우 컴포넌트의 이름이 나오지 않는 상황이 있다고 합니다. 그래서 위 코드처럼 fowradRef() 함수에 이름을 적용해 줍니다.
위 사진과 같이 잘 나오는 것을 볼 수 있습니다.
forwardRef 그러면 편하게 막 사용해도 될까?
일반적으로 forwardRef() 함수는 HTML Element 대신에 사용되는 최말단 컴포넌트 (ex. <Input>, <Button/>)을 대상으로 주로 사용되며, 그 보다 상위 컴포넌트에서 forwardRef() 함수를 사용하는 것은 권장되지 않습니다.
왜냐하면 어떤 컴포넌트의 내부에 있는 HTML Element의 레퍼런스를 외부에 있는 다른 컴포넌트에서 접근하도록 하는 것은 컴포넌트 간의 결합도(coupling)을 증가시켜 애플리케이션의 복잡도를 올리기 때문입니다.
forwardRef를 학습하는데 도움을 받은 블로그 글은 맨 아래 레퍼런스로 남겨두었습니다. 좋은 내용이니 참고해 보시면 좋을 듯싶습니다.
ref
input 컴포넌트 내에선 ref를 사용하였습니다. 목적은 handleClear 함수의 실행이 끝나면 사용자가 직접 다시 Input을 포커싱 해야 하는 고민의 해결책이었죠. 즉 ref를 사용하여 함수의 실행이 끝날 때 input을 다시 포커싱 하도록 작업하였습니다.
동작
useId
라벨을 클릭했을 때 Input 포커싱 하기 위해서 htmlFor과 id 속성을 사용하여 label 클릭 시 Input이 포커싱 되도록 하려 했습니다.
여기서 작은 문제가 발생했는데 Input Comp에 prop으로 id를 제공하지 않을 경우였습니다. 이를 해결하기 위해 propId가 없을 때 Input Comp 내에서 id를 자동 생성하도록 할 수 있었습니다. 방법은 useId() 훅을 사용하는 것, useId는 React에서 제공하는 훅으로 컴포넌트가 마운트 되는 시점에 고유한 식별자를 생성합니다.
const generatedId = useId();
const id = propId || generatedId;
동작
막 사용하면 안 돼!
React Docs를 살펴보면 해당 훅을 key값에 적용하기 위해 사용하는 것은 금물이라고 합니다.
Types
Omit<InputProps, "size"> (Omit은 번역하면 "생략"이라는 뜻입니다.)
- InputProps 타입에서 size 속성을 제외합니다. 이는 size 속성을 커스텀하기 위해 기존의 size 속성을 제거하고 새로운 size 속성을 정의하기 위함입니다.
- Omit<Type, Keys>는 TypeScript의 유틸리티 타입으로, Type에서 Keys에 해당하는 속성을 제거한 새로운 타입을 만듭니다.
즉 기존 MUI Input의 타입인 InputProps의 size 속성을 제거 후 확장하게 됩니다. 그렇게 size 속성을 새롭게 정의가 가능합니다.
마치며
이번 프로젝트에서 MUI Input을 커스텀하여 component를 구성하게 되면서 많은 것을 배울 수 있었습니다. 특히 컴포넌트를 만드는 것과 관련하여 중요한 여러 개념들을 이해하게 되었습니다.
배워간 것들 중 중요하다고 생각되는 것은 컴포넌트는 단순히 외형을 변경하는 것을 넘어서, 컴포넌트의 동작과 구조를 깊이 이해하는 것이 중요하다는 것을 알았습니다. 이를 통해 더욱 유연하고 강한 컴포넌트를 만들 수 있는 발판이 되었고, 향후 프로젝트에서도 이러한 발판들을 통해 더 나은 컴포넌트를 설계해 나갈 자신감이 생겼네요.
Reference
https://www.daleseo.com/react-forward-ref/
[React] forwardRef 사용법
Engineering Blog by Dale Seo
www.daleseo.com
https://velog.io/@seungrok-yoon/TextFieldInput-컴포넌트-설계하기
Input 컴포넌트 설계 및 개발기
컴포넌트를 직접 구현해보면서 설계 능력을 훈련해보기 1탄 - Input 컴포넌트입니다!
velog.io
'React' 카테고리의 다른 글
[React] Recoil 사용법 (RecoilRoot, atom, selector) (0) | 2023.04.11 |
---|---|
[React] Recoil-persist 사용하여 localStorage와 sessionStorage에 저장하기 (0) | 2023.04.09 |