이 글에서는...
- 리액트 개발자들 사이에서 자주 언급되고 인기있는 주제들을 정리합니다.
투두리스트를 구현할 때, 클릭한 투두의 done 여부가 바뀌는 state를 관리한다고 해보자.
import { useState } from "react";
const initialState = [
{ id: 1, content: "양파 사기", done: false },
{ id: 2, content: "리액트 공부", done: false },
]
const Component = () => {
const [todoList, setTodoList] = useState(initialState);
const handleToggleTodoClick = (id) => {
setTodoList(prev => prev.map(
((todo) => todo.id === id ? ({ ...todo, done: !todo.done }) : todo)
)
}
}
export default Component;
불변성을 유지하기 위해서 전개 연산자를 섞어 사용하던 상태를, immer로 쉽게 변경 & 적용할 수 있다.
import { produce } from "immer";
import { useState } from "react";
const initialState = [
{ id: 1, content: "양파 사기", done: false },
{ id: 2, content: "리액트 공부", done: false },
]
const Component = () => {
const [todoList, setTodoList] = useState(initialState);
const handleToggleTodoClick = (id) => {
setTodoList(
produce((draft) => {
const targetTodo = draft.find((todo) => todo.id === id);
targetTodo.done = !targetTodo.done;
})
);
}
}
export default Component;
리액트에서는 거의 금기시되던 일반적인 '=' 할당을 쉽게 사용할 수 있다.
사용할 때는 편리하게 접근하여 사용하는 것 같지만, immer가 자동으로 불변성 처리를 해 주어
복잡한 상태(배열이나 무거운 객체)를 핸들링할 때 매우 간편하다.
high-order components (hocs)를 통해 대상 컴포넌트를 Wrapping하여 플러그인처럼 사용할 수 있다.
const withAuthentication = (Component) => (props) => {
const { user, isFetching } = fetch(props.id);
if (isFetching) return <SkeletonUIComponent />
if (!user.isLoggedIn) return <NotLoggedInComponent />
return <Component {...props} user={user} />
}
const MyPageComponent = withAuthentication(({ user, ...props }) => {
return (
<div>{user.name}님 안녕하세요!</div>
)
})
렌더링을 직접적으로 제어할 수 있고, 감싸진 자식 컴포넌트의 props를 편집하는 등 기능을 Proxy처럼 사용할 수 있다는 것이 장점임.
그런데 한 컴포넌트에서 여러 개의 고차 컴포넌트가 필요하다면 어떨까?
이벤트 로그를 추가하고, 다크 모드를 추가하고, 특정 상태에 맞게 페이지를 리다이렉트하는 것까지 총 세 가지 hocs를 추가한다고 가정해보자.
const MyPageComponent = withAuthentication(withLogging(withTheme(withRouting((props) => {
...
}))))
코드가 복잡해질뿐더러 가독성도 좋은 편이라고 말하기는 어렵다.
const compose = (...hocs) => (Component) =>
hocs.reduceRight((acc, hoc) => hoc(acc), Component);
const MyPageComponent = compose(
withAuthentication,
withLogging,
withTheme,
withRouting
)(MyComponent)
compose 함수를 통해 가독성적인 측면에서 각 hocs들을 같은 depth에 올려두어
'어떤 플러그인이 적용되어있지?'를 빠르게 확인하고 쉽게 수정/삭제를 할 수 있도록 돕는다.
*hocs 이외에도 compose 패턴은 매우 유용하게 쓰일 수 있다 (이후 서술할 프롭 게터에서 더 알아보자)
<TodoList
todoList={todoList}
/>
const TodoList = ({ todoList }) => {
return (
<ul>
{todoList.map((todo) => (
<li>{todo.name}</li>
))}
</ul>
)
}
TodoList는 이제 단 하나만의 스타일을 가진 컴포넌트만을 렌더링할 수 있다.
TodoList 컴포넌트를 조금 더 유연하게 바꿀 수 있게 만들려면 어떻게 해야 할까?
<TodoList
renderTodo={(todo) => {
return <li>{todo.name}</li>
}}
todoList={todoList}
/>
const TodoList = ({ renderTodo, todoList }) => {
return (
<ul>
{renderTodo(todoList)}
</ul>
)
}
각 투두에 대하여 상위 컴포넌트가 이를 렌더링시키며 의존성이 역전된다.
개발자가 코드를 확인할 때 하위 컴포넌트에 진입하여 확인하지 않고, 상위에서 어떤 엘리먼트가
렌더링되는지를 확인할 수 있다는 장점이 있다. + 유연성도 좋음!
여러 개가 사용되는 프롭은 객체로 묶어서 전개 연산자로 표현할 수 있다.
드래그 & 드롭을 구현할 때는 onDragOver
, onDrop
, onDragEnd
, onDragStart
같은 이벤트들이 함께 필요하다.
이를 이벤트 입장에서도 한 번에 묶어서 보고, 컴포넌트에 주입할 때도 깔끔하게 표현할 수 있도록 만들어보자.
const draggableProps = {
onDragStart: (e) => {},
onDragEnd: (e) => {},
}
const droppableProps = {
onDragOver: (e) => {
e.preventDefault();
},
onDrop: (e) => {},
};
<DragZone {...draggableProps}/>
<DropZone {...droppableProps}/>
이렇게 프롭 컬렉션으로 깔끔하게 표현할 수 있다.
그런데 프롭 컬렉션을 사용하던 도중, 한 컴포넌트에서 약간의 변경이 필요하다고 생각해보자.
<DropZone
{...droppableProps}
onDragOver={() => {
alert("drag")
}}
/>
프롭 게터를 이용하면 프롭 컬렉션에서 정의해둔 함수를 가독성 좋게 사용할 수 있다.
const compose = (...functions) => (...args) =>
functions.forEach((fn) => fn?.(...args))
const getDroppableProps = ({ onDragOver }) => {
const defaultOnDragOver = (e) => {
e.preventDefault();
}
return {
onDragOver: compose(onDragOver, defaultOnDragOver),
}
}
<DropZone
{...getDroppableProps({
onDragOver: () => { ... }
})}
/>
훨씬 깔끔하게 표현 가능!
항상 '선택 사항'이기 때문에 많이 헷갈릴 수 있는 메모이제이션을 간단하게 이야기해보자.
React.memo : 컴포넌트를 메모이제이션할 때
useMemo : 값을 메모이제이션할 때
useCallback : 함수를 메모이제이션할 때
useMemo와 useCallback은 도대체 어떨 때 사용해야 합리적일까?
=> React.memo를 사용해도 리렌더링되는 컴포넌트를 최적화하는 데 좋다!
React.memo는 프롭을 얕게 비교하기 때문에, 스칼라 타입인 프롭에 한해서만 메모이제이션이 적용된다.
*스칼라 타입 : number, string, BigInt, undefined, null, boolean, symbol
=> 참조값이 아닌 저장된 값 자체를 사용하는 타입들
*참조 타입(논-스칼라 타입) : 함수, 객체, 배열
즉, 리렌더링이 발생할 때마다 참조 타입은 재생성되며 계속해서 참조값이 바뀌어 리액트가 동일한 프롭이라고 인식할 수 없다. => 내용이 동일하더라도 렌더링이 발생한다!
참조 타입을 비교할 때에는 값이 아닌 참조값, 즉 메모리에 저장된 주소값을 보기에 비교가 되지 않음.
[1, 2, 3] === [1, 2, 3] // false
==
"1,2,3이 저장된 2000번대 메모리 주소" === "1,2,3이 저장된 3000번대 메모리 주소"
프롭이 참조 타입이고, 하려는 작업이 무거운 작업일 때 useMemo를 사용할 수 있음.
const Component = ({ todoList }) => {
const [counter, setCounter] = useState(0);
const doneTodoList = todoList.filter(todo => todo.done);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>증가</button>
<DoneTodoList items={doneTodoList} />
</div>
)
}
이렇게 되면 버튼 컴포넌트를 클릭해 렌더링이 발생할 때마다 doneTodoList
값이 다시 계산된다.
doneTodoList 값의 계산 시간이 second 단위인 경우 매우 비효율적임.
const doneTodoList = useMemo(() => todoList.filter(todo => todo.done), [todoList])
두 번째 인자인 의존성 배열에 넣은 값이 변경될 때에만 계산이 진행된다.
그렇기에 useMemo를 사용하면 counter가 바뀌어 렌더링이 되더라도 값을 재계산하지 않음.
스칼라 타입을 위한 메모이제이션을 진행하는 경우, 오히려 메모화하는 비용이 훨씬 비싸게 되어 손해가 될 수 있다. 기본적으로 리액트가 스칼라 타입을 계산하는 속도는 매우 빠르며, useMemo를 사용하는 원초적인 이유인 참조 타입의 자료형에 대한 일관성 유지가 무색해지기 때문이다.
예시는 간단하지만, 복잡하게 값을 편집할 땐 처음 다룬 immer와 섞어 사용하면 매우 좋을 듯!
React.memo는 함수로 인해 메모화가 무력화당한다
<TodoList
onDoneAllClick={handleDoneClick}
/>
부모 컴포넌트에서 리렌더링이 발생할 때마다 handleDoneClick
함수도 다시 재생성되고, 그 때마다 함수의 참조값이 바뀌어 memo를 썼다 하더라도 자식 컴포넌트도 같이 렌더링이 발생한다.
이럴 때 useCallback을 사용하여 원하는 값이 변경되었을 때만 함수를 재생성해줄 수 있다.
const handleDoneClick = useCallback(() => {
... progress ...
}, [progress])
똑같은 코드를 짜면서도 어떤 식으로 짤 수 있는지에 대해 많이 고민해보는 것이 성장에 큰 도움이 된다고 느낀다.
최적화가 잘 되어있고, 읽기 쉬우며 가독성이 좋은 코드를 짤 수 있도록 노력해보자.
리액트에서 좋은 코드 짜기 힘들 때 읽으면 정말 좋은 글 같네요!