출처: NHN 코딩 컨벤션
페이먼츠 미션 이전에는 css속성을 선언할 때 아무런 규칙 없이 작성했다. 점점 복잡해지는 코드에 무언가 규칙이 필요하다고 생각했고 css 속성 선언 순서 컨벤션을 찾아보았다. 레이아웃과 같이 큰 단위부터 시작하여 작은 단위로 끝마치는 선언 순서가 직관적이라고 생각했고 바로 와닿았기에 NHN 코딩 컨벤션을 따르기로 결정했다.
카드번호: 1개의 fieldset
(부모)요소와 4개의 input
(자식)요소로 구성
const handleInputBlur = (e: React.FocusEvent<HTMLElement>) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
if (!(e.target instanceof HTMLInputElement)) return;
const inputs = [...cardNumber.slice(0, -1), e.target.value];
if (!isValidCardNumber(inputs)) {
setCardNumber(['', '', '', '']);
console.error(ERROR.INVALID_CARD_NUMBER);
allRef[0].current?.focus();
}
};
e.currentTarget
은 이벤트가 발생한 요소를 가리킨다.
e.relatedTarget
은 이벤트가 발생한 후에 포커스가 이동한 요소를 가리킨다.
event.currentTarget.contains(event.relatedTarget)
이 두가지를 이용하여 이벤트가 발생한 요소와 그 자식 요소들에 포커스가 남아있는지 확인할 수 있다.
위 조건문이 true
라면 이벤트가 발생한 요소나 그 자식 요소들 중에 포커스가 남아있음을,
위 조건문이 false
라면 이벤트가 발생한 요소나 그 자식 요소들 중에 포커스가 남아있지 않음을 의미한다.
즉 위 상황에서는 fieldset
요소에 onBlur
를 걸어두면 포커스가 카드번호 입력창 밖으로 빠져나갔을 때 카드번호 유효성 검사를 수행할 수 있다.
// preview.tsx
import React from 'react';
import type { Preview } from '@storybook/react';
import GlobalStyle from '../src/GlobalStyle';
const customViewports = {
Default: {
name: 'Default',
styles: {
width: '400px',
height: '700px',
},
},
};
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
layout: 'centered',
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
viewport: {
viewports: { ...customViewports },
defaultViewport: 'Default',
},
},
decorators: [
(Story) => (
<>
<GlobalStyle /> // 이렇게 추가해주면 된다.
<Story />
</>
),
],
};
export default preview;
페이먼츠 미션 1단계에서 만들었던 cardDataService 리팩터링 과정을 정리해보고자 한다.
1단계에 적었던 PR메시지이다. 앱 외부 저장소인 로컬스토리지와 UI로직을 분리하기 위한 도메인 객체 cardDataService를 만들었고 이 객체는 도메인 데이터를 관리하는 일을 담당한다.
// cardDataService.ts
import { Card } from '../types';
const LOCAL_STORAGE_KEY = { CARD_LIST: 'cardList' };
export const cardDataService = {
getCardList(): Card[] {
const rawCardList = localStorage.getItem(LOCAL_STORAGE_KEY.CARD_LIST);
return JSON.parse(rawCardList ?? '[]');
},
addNewCard(card: Card) {
const storedCardList = this.getCardList();
localStorage.setItem(LOCAL_STORAGE_KEY.CARD_LIST, JSON.stringify([card, ...storedCardList]));
},
};
컴포넌트에서 로컬스토리지에 직접 접근하고 있지는 않지만 cardDataService
를 사용하고 있었고 1,2 단계에서 위와 같은 피드백을 받았다. 그래서 고민해본 결과, UI로직과 스토리지 로직과의 의존성을 없애기 위해 cardDataService
를 useCardDataService
와 useLocalStorage
라는 훅으로 분리하여 개선해보았다.
// useCardDataService
import { Card } from '../types';
import { useLocalStorage } from './useLocalStorage';
const LOCAL_STORAGE_KEY = { CARD_LIST: 'cardList' };
export const useCardDataService = () => {
const [storedCardList, setCardList] = useLocalStorage<Card[]>(LOCAL_STORAGE_KEY.CARD_LIST, []);
const getCard = (id: string): Readonly<Card> | undefined => {
return storedCardList.find((card) => card.id === id);
};
const getCardList = (): Readonly<Card[]> => storedCardList;
const addNewCard = (card: Card) => {
setCardList([card, ...storedCardList]);
};
const addAliasToCard = (id: string, alias: string) => {
const updatedCardList = storedCardList.map((card) => {
if (card.id === id) return { ...card, cardAlias: alias };
return card;
});
setCardList(updatedCardList);
};
return { getCard, getCardList, addNewCard, addAliasToCard };
};
// useLocalStorage
import { useState } from 'react';
export const useLocalStorage = <T extends unknown>(
key: string,
initialValue: T,
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
if (error instanceof Error) console.error(error.message);
return initialValue;
}
});
const setValue = (value: T) => {
try {
const valueToStore = value;
if (typeof window === 'undefined') throw new Error('Cannot store Value');
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
if (error instanceof Error) console.error(error.message);
}
};
return [storedValue, setValue];
};
👆이렇게 리팩터링을 마무리했고 이번 미션에서 관심사 분리에 대한 훅의 활용방법에 대해 배워갈 수 있었다.
폼 입력이 완료되면 ‘다음’ 버튼이 렌더링되는 기능을 구현하고 이에 대한 코드리뷰를 받으면서 알게 된 내용을 정리해보았다.
// src/hooks/useCardRegisterFormValidation.ts
export const useCardRegisterFormValidation = ({
cardNumber,
expirationDate,
ownerName,
securityCode,
password,
}: Card) => {
const isValidCardForm, setIsValidCardForm = useState(false);
useEffect(() => {
const isValidForm =
isValidCardNumber(cardNumber) &&
isValidExpirationDate(expirationDate) &&
isValidOwnerName(ownerName) &&
isValidSecurityCode(securityCode) &&
isValidPassword(password);
setIsValidCardForm(isValidForm);
}, [cardNumber, expirationDate, ownerName, securityCode, password]);
return isValidCardForm;
👆 카드 등록 폼에 입력된 값들에 대한 유효성 검사를 수행한 후 그 결과를 반환하는 커스텀 훅이다.
isValidCardForm
이라는 state를 만들고 기존의 state들(cardNumber, expirationDate, ownerName, …
)에 대한 유효성 검사 결과를 모두 합하여 setIsValidCardForm
으로 상태값을 변경한 후 반환useEffect
안에 넣어줌// src/components/CardRegisterForm.tsx
export function CardRegisterForm() {
...
const [isValidCardForm] = useCardRegisterFormValidation({
cardNumber,
expirationDate,
ownerName,
securityCode,
password,
});
...
return (
<Style.Wrapper onSubmit={handleCardInfoSubmit}>
<CardViewer cardNumber={cardNumber} expirationDate={expirationDate} ownerName={ownerName} />
<Style.InputContainer>
...
</Style.InputContainer>
<Style.ButtonContainer>
{isValidCardForm && <Style.NextButton>다음</Style.NextButton>}
</Style.ButtonContainer>
</Style.Wrapper>
);
👆 isValidCardForm
이라는 상태 값에 따라 ‘다음’ 버튼을 조건부 렌더링해주는 코드
위 코드의 문제점은 무엇일까? 일단 리뷰어에게 받은 코드리뷰를 참고해보자.
계산된 값
이란 뭘까?
Q. 개발자가 하나하나 제어하는 값 대신 계산된 값(computed value)을 고려하라는 말의 의미는 무엇인가요?
// useCardRegisterFormValidation.ts
export const useCardRegisterFormValidation = ({
cardNumber,
expirationDate,
ownerName,
securityCode,
password,
}: Card) => {
const [isValidCardForm, setIsValidCardForm] = useState(false);
useEffect(() => {
const isValidForm =
isValidCardNumber(cardNumber) &&
isValidExpirationDate(expirationDate) &&
isValidOwnerName(ownerName) &&
isValidSecurityCode(securityCode) &&
isValidPassword(password);
setIsValidCardForm(isValidForm);
}, [cardNumber, expirationDate, ownerName, securityCode, password]);
return isValidCardForm;
};
카드 등록 폼의 유효성검사를 담당하는 커스텀 훅에서
useEffect
를 이용해 인풋의 입력값들이 변경되면 폼의 유효성에 대해 계산한 값(true
/false
) 으로isValidCardForm
의 값을 변경하고 반환하니까 ‘계산된 값’을 사용한 게 아닌가란 생각이 들었습니다.그런데 이게 아니라면 계산된 값이라는 건 어떤 걸 의미하는 것인지 궁금합니다.
A.
리액트에서의 상태는 렌더링마다 고유의 상태를 가지며 내부에 있는 사소한 변수조차 렌더링마다 값이 달라질 수 있어요. 4기의 도리가 작성한 글 한번 확인해보시고요
제시해주신 커스텀 훅의
isValidForm
<= 이게 바로 계산된 값입니다
isValidCardForm
<= 이건 계산된 값이 아니에요첵스의 프로필에 나오는 강아지에게
- 9시, 6시에 밥 먹어라고 말만하고 알아서 먹도록 둔다 (계산된 값)
- 9시, 6시가 되면 밥 먹으라고 직접 말한다 (setState())
✨ 피드백을 다시 정리해보면?
isValidCardForm
이라는 state를 사용하는 것은 사이드 이펙트 발생 + 관리 포인트가 늘게 되는 문제가 있다.isValidCardForm
)를 만들지 말고, 기존 state 변수들(cardNumber
등)을 이용해 카드 등록 폼의 유효성에 대해 계산할 수 있으니 계산된 값(isValidForm
)을 이용해라!export const useCardRegisterForm = () => {
...
const isValidCardForm =
isValidCardNumber(cardNumber) &&
isValidExpirationDate(expirationDate) &&
isValidOwnerName(ownerName) &&
isValidSecurityCode(securityCode) &&
isValidPassword(password);
return {
...
isValidCardForm,
};
};
Be the best version of you!