DOM을 직접 제어하지 않고 데이터를 이용한 렌더링을 연습한다.
카테고리를 추가할 수 있게 되면서 데이터 객체가 필요함을 느껴 리액트로 넘어가기 전에 상태 관리를 조금 더 다루어 보기로 했다. DOM을 직접 제어할 때는 못 느꼈는데 변경을 감지하고 렌더링 하는 것은 신경 쓸 부분이 한두 개가 아니었다. 완벽히 웹컴포넌트를 구현하지는 못했고, 클래스와 함수가 뒤섞여 조금은 혼란스럽게 맛보기를 했다.
변경이 생길 때마다 데이터를 갱신하고, 그 값을 이용해 리렌더링을 구현했다. 마지막에 완성된 코드를 보니 또 어디서 많이 본 구조였다. 그냥 지나칠 수 없어 알고 있는 지식을 총동원해 useState와 useReducer를 비슷하게 따라 했다.
<select>
태그의 option을 위한 컴포넌트 분리<select>
태그의 value 속성을 사용해 선택된 카테고리 분기<header id="header">...</header>
<div class="section__btn">
<button type="button" class="section__btn--text">기본형</button>
<button type="button" class="section__btn--check">체크형</button>
</div>
<main id="section"></main>
<section class="control">...</section>
section을 추가하기 위한 버튼을 2개로 나누어 item 타입을 분리한다.
구현이 된 이후 작성한 Flow를 보면 이해가 잘되는데 처음에는 어떻게 접근해야 할지 알듯 말 듯했다. DOM을 직접 제어하는 것도 하나의 상태 변경인 같은데 무엇이 어떻게 다른 지 잘 와닿지 않았던 것 같다. 리액트였다면 새로운 카테고리를 받아서 생성하려 할 때 무엇을 했을까?를 고민해 보았을 때 카테고리 초기값을 들고 그 값을 활용해 <Section/>
컴포넌트를 렌더링 하면 되겠다고 생각했다. 그래서 가장 먼저 sectionList라는 빈 배열을 만들고, 원하는 값을 가지는 더미 데이터를 적용했다.
let sectionList = [
{
id: 0,
name: '쇼핑',
title: '사고 말 테다',
}
]
이전에는 카테고리 추가 버튼에 이벤트가 발생했을 때 Section 클래스를 호출했다면 이번에는 입력받은 값으로 sectionList에 추가해 주었다. 그리고 렌더링이 되는 시점의 데이터를 뿌려주면서 화면을 그렸다.
sectionList에 새로운 데이터가 추가되어도 화면은 변화가 없다. console에 찍어보면 분명 달라졌다. 데이터 객체로 반복문을 돌면서 화면을 만들고 있는데 바뀐 데이터로 새로운 화면을 그리려면 DOM을 생성했던 호출이 한 번 더 필요하다. 리렌더링이 필요한 시점이다. 이를 위한 초기화 함수를 작성해보자.
export const init = () => {
main.innerHTML = '';
section.forEach((section) => {
const newSection = new Section(section, item);
main.append(newSection.section);
item.forEach((item) => {
item.sectionId === section.id &&
newSection.section.children[0].classList.add('active');
});
});
select.innerHTML = '';
option.forEach((option) => {
new SelectOption(option);
});
};
init 함수는 App 클래스가 호출될 때 한번, 그리고 이후 데이터에 변경이 생기면 다시 호출된다. 이제 일반 변수와 state의 차이가 느껴진다. 일반 변수는 선언된 위치에 따라 함수가 호출되거나 리렌더링이 되면 값이 초기화되지만 state는 그 값을 기억한다. setState로 변화를 감지하고 리렌더링 하면서 초기화를 건너뛰고 변경된 state를 화면에 적용시킨다. 일반 변수는 동적으로 변화된 값을 자동으로 감지하고 렌더링 되지 않는다. 만일 되더라도 값이 초기화되지 않아야 한다.
이전까지는 추가, 수정, 삭제된 데이터가 그 즉시 DOM요소에 적용되도록 했지만 지금은 데이터를 객체로 관리하고 최신의 객체 값으로 화면을 그린다. 카테고리들을 담고 있는 <main>
과 카테고리에 따라 달라지는 <select>
를 상태에 따라 다시 그려야 하기 때문에 초기화를 할 때마다 2개 요소의 innerHTML을 초기화해 주었다. 그렇지 않으면 반복적으로 쌓이게 된다. 잘 동작하지만 현재 모든 UI를 다시 렌더링하기 때문에, 필요할 때마다 전체를 초기화하는 방식은 비효율적인 문제가 있다.
const addSection = (type, name, title) => {
const newData = [...sectionList, { id: sectionId, type, name, title }];
sectionList = newData;
sectionId++;
};
const addOption = (option) => {
const newData = [...optionList, option];
optionList = newData;
};
데이터 변경을 처리하는 함수 중 일부이다. 1개의 함수로 모든 로직을 처리할 수 있으면 깔끔하겠다. 리액트의 useState의 역할을 하는 함수가 있으면 될 것 같다.
첫 번째 시도
const useState = (initialData) => {
let state = initialData;
const setState = (newData) => {
state = newData;
};
return [state, setState];
};
초기값을 받아 내부 state 변수에 저장하고, state를 변경하는 내부 함수도 만들어서 이 2개의 값을 배열로 반환했다. 놀랍도록 아무 일도 일어나지 않는다. 디버깅을 해보니 useState 함수 내에서는 변경된 값이 잘 적용되지만, state를 사용하는 곳에서는 변경된 값이 적용된 state를 읽지 못하고 초기값만 계속 갖고 있다. 그럼 useState 함수 내에 적용된 state를 꺼내오면 되지 않을까?
두 번째 시도
const useState = (initialData) => {
let data = initialData;
const state = () => data;
const setState = (newData) => {
data = newData;
};
return [state, setState];
};
state도 함수로 만들었다. 대신 state 값을 사용할 때도 함수를 호출해서 반환값을 써야 한다.
사용하기
setSection([...section(), { id: sectionId, type, sectionCategory, title }]);
setOption([...option(), sectionCategory]);
문제없이 변경된 데이터로 잘 가져와서 생성한다. 두 가지 방법의 차이점은 위에 말한 useState 함수 안에만 적용되어 있는, 변경이 적용된 값을 쓰는 것에 있다. state 함수를 호출하면 직접 만든 useState는 클로저가 되고, 이전에 setState로 변경된 적이 있다면 useState의 data는 변경된 값을 가지고 있다. 그럼 반환값은 변경이 적용된 data 값이 된다.
다만 만든 useState를 클래스(또는 함수)의 바깥에 선언해야 값이 초기화되지 않는다. 리액트 useState는 컴포넌트가 리렌더링이 되어도 내부에 선언된 state가 초기화되지 않는다. 외부 어딘가에 글로벌하게 저장해서 사용하는 것 같다.
app.js에서는 Section, Option, Item을 생성할 수 있다. 그래서 생성과 관련된 상태를 같은 파일 안에서 처리할 수 있었다. 하지만 수정과 삭제는 ItemComponent.js에서 처리하고 있고, 전체 item 목록은 app.js가 갖고 있는 문제가 생겼다. 데이터를 전달하고 전달해서 외부 주입을 받아 사용해야 한다. 이 부분에서 완벽히 useState를 대체할 수 있도록 구현하지 못했다.
setState를 사용해 불변성을 지키다 보면 코드가 길어진다. 그럴 때 useReducer를 사용해서 setState를 분리할 수 있다. 간단히 구조만 확인할 수 있도록 만인의 예제 Counter로 연습해 본다.
useReducer 구현
const useReducer = (reducer, intialState) => {
let data = intialState;
const state = () => {
return data;
};
const dispatch = (action) => {
data = reducer(state(), action);
};
return [state, dispatch];
};
useState 구조를 응용한다. state는 동일하게 함수이고, dispatch 함수로 setState를 대신했다. dispatch 함수는 인자로 받은 reducer의 결괏값을 반환한다. reducer는 state 함수의 반환값을 인자로 받는다.
reducer 함수
const countReducer = (value, action) => {
switch (action.type) {
case 'add':
return {
...value,
count: value.count + 1,
};
default:
throw new Error('타입 확인');
}
};
사용하기
const value = {
count: 0
}
const Counter = () => {
const [count, dispatch] = useReducer(countReducer, value);
const btn = document.querySelector('.btn');
btn.addEventListener('click', () => {
dispatch({ type: 'add' });
dispatch({ type: 'add' });
dispatch({ type: 'add' });
dispatch({ type: 'add' });
dispatch({ type: 'add' });
});
};
리액트에서의 useReducer와 사용 방법은 동일하다. 여전히 count state는 함수이기 때문에 호출로 사용해야 한다. dispatch 함수에 type과 state 변경에 적용할 값을 객체로 전달한다. count가 1씩 증가한다.