리액트에는 상태라는 개념이 존재합니다. 시도 때도 없이 바뀌는 이런 상태를 어떻게 다루어야 버그 없는 컴포넌트를 만들 수 있을까요?
물론 상태를 대~~충 다루어도 컴포넌트를 만들고 상태를 다루는 데는 큰 무리가 없을 겁니다. 하지만 서비스의 규모가 점점 커지다 보면 어느 순간 내가 만들었던 상태들이 제멋대로 변하면서 원치 않는 상황을 마주하는 경험을 하게 될 것입니다. 그제야 상태의 구조를 바꾸기 위해 뜯어고치기 시작한다면 그땐, 이미 상태가 꼬일 대로 꼬이지 않았을까요?
그래서 저희는 상태를 잘 구조화하여 리팩터링을 쉽게 할 수 있게 해야 하며, 오류 없이 상태를 업데이트를 할 수 있어야 합니다. 그러면 어떻게 상태를 만들어야 할까요? 가이드라인은 가까운 곳에 있습니다. 바로 리액트 공식문서입니다. 리액트 공식문서에서는 상태를 구조화하는 다섯 가지 원칙(Choosing the State Structure)을 소개하고 있습니다. 이 챕터는 이를 토대로 정리한 내용입니다.
1. Group related state.
첫 번째 원칙은 관련된 state를 그룹화하는 것입니다. 그러면 무엇이 관련이 되었다고 할 수 있을까요? 어떠한 이벤트로 인해 동시에 변하는 상태들이 관련이 있다고 할 수 있습니다. 하나씩 상태를 업데이트를 하는 것보단, 하나의 상태로 그룹화여 한꺼번에 상태를 업데이트를 하면 됩니다.
if some two state variables always change together, it might be a good idea to unify them into a single state variable.
첫 번째 원칙의 핵심 문장입니다. "만약 두 개의 staet 변수가 항상 함께 변경된다면, 이들을 하나의 state 변수로 통합하는 것이 좋은 선택지가 될 것이다."
공식문서에서는 이 원칙에 대한 예제로 마우스의 움직임에 따라 변하는 x, y 좌표 상태를 소개하고 있습니다. x, y좌표는 하나의 이벤트(마우스 움직임)로 인해 동시에 변경됩니다. 다음의 코드를 살펴보면서 어떤 구조가 좋은지 생각해 봅시다.
// 그룹화 x
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// 그룹화 o
const [position, setPosition] = useState({ x: 0, y: 0 });
첫 번째 원칙에 의하면 x, y 좌표 상태를 그룹화하여 관리하는 것이 좋은 선택입니다. 이를 다르게 생각한다면 동시에 변경되지 않는 상태는 과연 그룹화가 좋은지 고민해 봐야겠습니다.
Another case where you’ll group data into an object or an array is when you don’t know how many pieces of state you’ll need. For example, it’s helpful when you have a form where the user can add custom fields.
위 내용은 공식문서에서 소개하고 있는 상태를 그룹화하는 또 다른 예시입니다. 코드로 설명이 되어 있지 않아 정확히 어떤 경우인지는 모르겠지만, 짐작하는 상황은 상태에 속성(객체라면 키-값, 배열이라면 요소)이 얼마만큼 추가되어야 하는지 모르는 상황입니다.
내가 좋아하는 과일을 추가하는 상태가 있다고 생각해 보면, 위의 상황이 이해될 것입니다. 사용자는 하나의 과일을 좋아할 수도 있고 10가지의 과일을 좋아할 수도 있습니다. 이런 경우, 10개의 상태를 만드는 것보다 하나의 상태를 배열로 만들어 배열에 계속 추가하는 방법이 더욱 효율적입니다.
2. Avoid contradictions in state
두 번째 원칙은 상태의 모순을 피하는 것입니다. 상태의 모순이란 무엇일까? 발생하면 안 되는 상태라고 생각합니다. 예를 들어 비동기 작업을 처리하는 Promise 객체는 다음 중 하나의 상태를 가집니다.
- pending
- fulfilled
- rejected
즉, Prmoise 객체는 하나의 상태만 가져야 하는데 그렇지 않고 두 가지 이상의 상태를 동시에 가질 때, 상태의 모순이 발생합니다.
다음과 같이 Promise 상태를 관리한다면 상태의 모순이 발생할 가능성이 존재합니다.
const [isPending, setIsPending] = useState(true)
const [isFulfilled, setIsFulfilled] = useState(false)
const [isRejected, setIsRejected] = useState(false)
실수로 인해 setIsFulfilled(true)가 호출되었지만 setIsPending(false)가 호출되지 않는다면 isPending과 isFulfilled가 동시가 동시에 true가 되는 상황이 발생될 수 있습니다. 빠르게 오류를 발견하여 수정할 수 있지만 컴포넌트가 복잡해지면 이를 발견하는 것은 쉽지 않을 수도 있습니다.
하지만 다음과 같이 Promise 상태를 관리 한다면 하나의 상태만 가지는 것을 확신할 수 있습니다.
const [status, setStatus] = useState<"pending" | "fulfilled" | "rejected">("pending")
3. Avoid redundant state
세 번째 원칙은 불필요한 상태를 피하는 것입니다. 즉, 필요없는 상태를 만들지 않는 것입니다. 무엇이 필요 없을까요? 공식문서에서 소개하는 불필요한 상태는 다음과 같습니다.
If you can calculate some information from the component’s props or its existing state variables during rendering, you should not put that information into that component’s state.
렌더링 중에 컴포넌트의 props 혹은 이미 존재하는 state 변수로 어떤 정보를 취득할 수 있다면 해당 정보는 state로 관리하지 않아야 합니다.
다음의 예제를 살펴보면 세 번째 원칙이 무엇인지 확실한 감을 잡을 수 있습니다.
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
과연 fullName이라는 상태가 필요할까요?
fullName은 firstName과 lastName을 통해 계산될 수 있으므로 상태로 존재하지 않아도 됩니다. 즉, 상태에서 제거하는 것이 좋습니다. 따라서 다음과 같이 state 변수로 관리하지 말고 일반 변수로 만들어 활용하면 됩니다.
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = firstName + " " + lastName
상태가 바뀔 때 마다 해당 상태를 사용하고 있는 변수는 새롭게 계산되어 렌더링이 됩니다. 때문에 fullName을 계산하기 위한 특별한 작업이 필요 없습니다.
4. Avoid duplication in state
네 번째 원칙은 상태의 중복을 피하는 것입니다. 즉, 같은 상태를 여러 번 사용하는 것은 좋지 않습니다. 공식문서의 예제를 가져왔습니다.
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(
items[0]
);
// ...
}
items, selectedItem 상태를 살펴보면 items의 요소 중 하나를 selectedItem 상태로 가지고 있습니다. 여기서 중복이 발생됩니다.
그러면 어떤 상황에서 중복된 상태에 대한 오류가 나타날까요? 다음의 상황을 가정해 보겠습니다.
- items의 요소 중 하나를 선택합니다. 선택된 요소는 selectedItem 상태가 됩니다.
- 선택한 요소를 수정합니다. 즉, items의 상태가 업데이트됩니다.
- 하지만 selectedItem는 수정하지 않습니다.(함께 수정을 한다면 문제가 없습니다. 하지만 이는 효율적인 상태 관리라고 할 수 없습니다.)
위와 같은 상황이 발생된다면 items와 selectedItem은 서로 연관되어 있지만 서로 같은 값을 바라보고 있지 않습니다. 이를 개발자의 실수라고 생각할 수 있지만, 상태를 더 효율적으로 구조화한다면 실수 없이 상태를 업데이트할 수 있습니다.
어떻게 효율적으로 구조화를 할 수 있을까요? 상태의 중복을 피하고 필수 상태만 가져오면 됩니다. items의 값들 중 특정 값이 선택되었다는 것을 어떻게 알 수 있을까요? itmes의 요소들을 보면 모두 id를 가지고 있습니다. 그러면 선택된 items의 요소의 id를 상태로 관리하면 문제는 해결됩니다. 즉, id가 필수 상태입니다.
"난 다 몰라도 돼. 단, 이것(필수 상태)만은 알고 있을 거야."
다음은 상태의 중복을 피하고 필수 상태를 가져와 상태를 구조화 한 코드입니다.
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
const selectedItem = items.find(item =>
item.id === selectedId
);
// ...
}
setItems가 실행되면 items의 요소들이 수정되고 렌더링이 발생됩니다. 이에 따라 selectedItem은 새로운 값을 가지게 됩니다.
이는 세 번째 원칙인 불필요한 상태를 피하는 것과 관련 있습니다. 선택된 항목은 전체 항목과 선택된 항목의 아이디를 통해 렌더링 중에 계산할 수 있기 때문입니다.
5. Avoid deeply nested state
마지막인 다섯 번째 원칙은 깊게 중첩된 상태를 피하는 것입니다. 깊게 중첩되었다는 것은 객체 안에 객체 안에 객체,,, 혹은 2중, 3중 배열을 생각하면 됩니다. 이러한 것이 상태로 존재한다면 어떨까요? 다루어보지 않아도 상상만으로도 업데이트하는 것은 굉장히 복잡할 것입니다. 때문에 깊게 중첩된 상태를 피하고 상태를 최대한 flat(평평, 정규화라고도 불린다.)하게 만드는 것이 중요합니다.
If the state is too nested to update easily, consider making it “flat”.
"상태가 만약 너무 깊게 중첩되어 업데이트하기 어렵다면, flat(평평)하게 만드는 것을 고려하자."
공식문서의 예시를 가져왔습니다. 다음의 객체가 상태로 있다고 생각해 봅시다.
export const initialTravelPlan = {
id: 0,
title: '(Root)',
childPlaces: [{
id: 1,
title: 'Earth',
childPlaces: [{
id: 2,
title: 'Africa',
childPlaces: [{
id: 3,
title: 'Botswana',
childPlaces: []
}, {
id: 4,
title: 'Egypt',
childPlaces: []
}, {
id: 5,
title: 'Kenya',
childPlaces: []
}, {
id: 6,
title: 'Madagascar',
childPlaces: []
}, {
id: 7,
title: 'Morocco',
childPlaces: []
}, {
id: 8,
title: 'Nigeria',
childPlaces: []
}, {
id: 9,
title: 'South Africa',
childPlaces: []
}]
}, {
id: 10,
title: 'Americas',
childPlaces: [{
id: 11,
title: 'Argentina',
childPlaces: []
}, {
id: 12,
title: 'Brazil',
childPlaces: []
}, {
id: 13,
title: 'Barbados',
childPlaces: []
}, {
id: 14,
title: 'Canada',
childPlaces: []
},
// ...
};
네, 너무 길어서 생략을 했습니다. 중요한 점은 깊게 중첩된 객체를 제거하는 상황이 발생했을 때, 어떻게 로직을 작성해야 하는지입니다. 제거하기 위해선 최상위부터 제거하려는 객체가 위치하는 깊이까지 객체를 복사해야 합니다. 이런 과정은 복잡하여 실수가 일어날 수 있습니다.
중첩된 상태를 다음과 같이 flat(평평)하게 만들게 된다면 보다 쉽게 상태를 업데이트할 수 있습니다. 각 장소의 id를 바탕으로 평평한 객체로 바꾸었습니다.
export const initialTravelPlan = {
0: {
id: 0,
title: '(Root)',
childIds: [1, 43, 47],
},
1: {
id: 1,
title: 'Earth',
childIds: [2, 10, 19, 27, 35]
},
2: {
id: 2,
title: 'Africa',
childIds: [3, 4, 5, 6 , 7, 8, 9]
},
3: {
id: 3,
title: 'Botswana',
childIds: []
},
4: {
id: 4,
title: 'Egypt',
childIds: []
},
5: {
id: 5,
title: 'Kenya',
childIds: []
},
6: {
id: 6,
title: 'Madagascar',
childIds: []
},
7: {
id: 7,
title: 'Morocco',
childIds: []
},
8: {
id: 8,
title: 'Nigeria',
childIds: []
},
9: {
id: 9,
title: 'South Africa',
childIds: []
},
10: {
id: 10,
title: 'Americas',
childIds: [11, 12, 13, 14, 15, 16, 17, 18],
},
11: {
id: 11,
title: 'Argentina',
childIds: []
},
// ...
};
더 이상 깊게 중첩된 객체는 없습니다. 이렇게 상태를 관리하게 된다면 상태의 업데이트는 보다 수월합니다. 다음과 같은 두 가지 과정만 수행된다면 장소는 제거됩니다.
- The updated version of its parent place should exclude the removed ID from its childIds array.
- The updated version of the root “table” object should include the updated version of the parent place.
보다 자세한 예시를 보려면 공식문서를 참고하시길 바랍니다. Avoid deeply nested state
'⚛️ 리액트' 카테고리의 다른 글
타이머를 구현하면서 마주한 문제와 해결 (1) | 2024.03.06 |
---|---|
React Router v6.4 이상에서 Router 다루기(RouterProvider, createBrowserRouter, Route) (0) | 2023.06.03 |
🧭 Paths Alias 사용하기(CRA, Storybook, Jest 설정도 함께) (0) | 2023.04.25 |