useState
클래스 컴포넌트에서 사용하는 this.setState와 같은 기능으로 컴포넌트 내부의 상태를 관리할 수 있습니다.
간단한 예제 : (https://reactjs.org/docs/hooks-state.html)
// usage
// const [value, setValue] = useState(initialValue);
import React, { useState } from 'react';
function Example() {
const [count, setCount] = useState(0);
return (<span>{count}</span>);
};
만약 두 개 이상의 특정 값을 확인하고 해당 값에 대해서 변화가 있을 경우 동시성(synchoronicity)을 보장하는 re-render를 하려고 한다면, 해당 상태들을 각각 선언하는 것으로는 해결할 수 없습니다.
A, B, C 필드에 대해서 필수 체크를 하지 않은 상황에서 하단의 '확인' 버튼을 누를 경우 각 A, B, C 필드를 빨간색으로 변경하고 싶어서 각각을 상태 선언을 하게 되면 동시에 빨간색으로 변경할 수 없습니다. 그 이유는 How does React Hooks re-renders a function Component? 에 잘 소개해 주고 있으니 한번 참고하시면 좋겠습니다.
요약을 하자면 다음과 같습니다.
1. The render function accepts a Component and renders it
2. The useState function accepts a state and returns an array with the current state and a function to update the state.
3. The function returned by useState will re-render the component automatically when invoked.
해당 방식으로 동작하기 때문에 useState의 함수의 대상이 되는 value들은 한 번에 render 되지만, useState의 함수 자체는 각각 실행됩니다.
// without synchoronicity
const [a, setA] = useState<boolean>(false);
const [b, setB] = useState<boolean>(false);
const [c, setC] = useState<boolean>(false);
...
const handleSubmit = (terms: object) => {
if(something) {
...
// if terms.a is invalid
// setA(true);
// if terms.b is invalid
// setB(true);
// if terms.a and terms.b is invalid
// setA(true);
// setB(true);
...
}
}
해당 상태들을 하나로 합치고, spread operator나 Object.assign을 통해서 상태를 변경하는 것을 통해 동시성을 보장할 수 있습니다.
export interface MandatoryTermStyle {
a: boolean;
b: boolean;
c: boolean;
}
// with synchoronicity
const initialStyle = {
a: false,
b: false,
c: false,
} as MandatoryTermStyle;
const [mandatoryTermStyle, setMandatoryTermStyle] = useState<MandatoryTermStyle>(initialStyle);
...
const handleSubmit = (terms: object) => {
if(something) {
...
// if terms.a is invalid
// setMandatoryTermStyle({...mandatoryTermStyle, a: true});
// if terms.b is invalid
// setMandatoryTermStyle({...mandatoryTermStyle, b: true});
// if terms.a and terms.b is invalid
// setMandatoryTermStyle({...mandatoryTermStyle, a: true, b: true});
...
}
}
동시성을 보장하기 위해서 간단한 상태들의 관리를 통합해서 처리할 수 있지만, 결국은 변경하지 않는 값들을 위해서 많은 spread operator를 사용해야 하는 것을 의미합니다. 이에 더해서 event handler, setState 등은 React.FC 속에 존재하기 때문에 앞서 있었던 케이스처럼 해당 분기 처리를 custom hooks를 작성하지 않는 한 컴포넌트와 로직이 합쳐지는 결과를 낳게 되어서 소스 코드를 이해하기 어려워지는 상황이 생길 수 있습니다.
useReducer
node_module/@types/react/index.d.ts에 다음과 같이 작성되어 있습니다.
function useReducer<R extends ReducerWithoutAction<any>, I>(
reducer: R,
initializerArg: I,
initializer: (arg: I) => ReducerStateWithoutAction<R>
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
/**
* An alternative to `useState`.
*
* `useReducer` is usually preferable to `useState` when you have complex state logic that involves
* multiple sub-values. It also lets you optimize performance for components that trigger deep
* updates because you can pass `dispatch` down instead of callbacks.
*
* @version 16.8.0
* @see https://reactjs.org/docs/hooks-reference.html#usereducer
*/
useState의 대체 함수이며 state와 dispatch method를 쌍을 이루어 반환합니다. Redux에서 구사하는 동작 방식과 유사합니다. 주석처럼 useReducer는 하위 객체 tree가 복잡한 경우 유리하며 성능상으로 callback을 대신하는 dispatch method를 전달할 수 있어 이점을 가집니다. (https://reactjs.org/docs/hooks-reference.html#usereducer)
여러 개의 필터를 가지고 있는 경우 reducer를 통해서 다음과 같이 작성할 수 있습니다.
const reducer = (state, action) => {
switch(action.type) {
case 'TOGGLE_FILTER_EXPAND':
return { ...state, isFilterExpand: !state.isFilterExpand };
case 'CHANGE_FILTER':
let filter = state.filter || {};
if(action.category) {
filter = { ...filter, category: action.category };
}
if(action.brand) {
filter = { ...filter, brand: action.brand };
}
if(action.usage) {
filter = { ...filter, usage: action.usage };
}
if(action.keyword) {
filter = { ...filter, keyword: action.keyword };
}
if(action.price) {
switch(action.price.length) {
case 2:
filter = { ...filter, price: { from: action.price[0], to: action.price[1] } };
case 1:
const price = action.price > 50000000 ? { from: 0, to: action.price[0] } : { to: action.price[0], to: 100000000};
filter = { ...filter, price: price };
case 0:
filter = { ...filter, price: { from: 0, to: 100000000 } };
}
}
return { ...state, filter };
...
default:
throw new Error('there is no reducer action type as given');
}
}
const initialState = { isFilterExpand: false };
const Filter: React.FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
...
<Category filter={state.category} dispatch={dispatch} />
</>
);
}
reducer function을 작성하는 것이 Functional Component 외부에서 작성을 하기 때문에 dispatch 함수가 re-render 이후에도 변경되지 않는 것을 보장할 수 있습니다. 이를 통해서 useEffect / useCallback의 dependency list에 추가가 필요 없도록 합니다. (물론 useState를 다루는 logic을 callback으로 밖에 작성을 가능하게 할 수 있습니다.) eslint의 'react-hooks/exhaustive-deps' rule은 hooks작성에 있어서 변화가 없을 경우 re-render를 방지하는 중요한 기능을 합니다. 또한 CRA로 생성된 프로젝트라면, default rule sets에 포함되어 있습니다. 그런데 pre-commit와 같은 git hook에 'npm run lint -- --fix'와 같은 명령어를 사용하고 있다면 작성자의 실수에 대해서 auto fix로 프로그램이 고쳐준 것이 또 다른 문제를 만들 수 있습니다.
const myComponent: React.FC = () => {
const apiCallService = new ApiCallService();
const [clickNumber, setClickNumber] = useState<number>(0);
const [title, setTitle] = useState<string>('initialTitle');
useEffect(() => {
setClickNumber(clickNumber + 1);
apiCallService.getTitle().then(response => {
setTitle(getThumbnail(response));
});
}, []);
const myFunction: void = () => {
console.log('function called');
setClickNumber(clickNumber + 1);
};
const getThumbnail: string = (target: string) => {
if (target.includes('weekly: ')) {
return target.toUpperCase();
} else {
return target;
}
}
return (
<>
<button onClick={myFunction}>
BUTTON {title}
</button>
<span>
{clickNumber}
</span>
</>
);
};
위와 같이 첫 화면을 그린 후 clickNumber을 하나 올리고 서비스를 호출해서 해당 값이 weekly로 시작하는 경우 대문자로 title을 사용하고자 하는 컴포넌트가 있다면 얼핏 보기에는 문제가 없어 보입니다. 그러나 'npm run lint:fix:source'를 실행해 보면 소스가 변화가 생기는 것을 확인할 수 있습니다. useEffect에 의존성 배열로 clickNumber, apiCallService, getThumbnail이 추가가 되며 첫 화면을 그린 이후에 의도와 다르게 작동하는 것을 볼 수 있습니다. 그렇다면 eslint가 잘못한 것으로 생각을 하며 'eslint-disable-next-line react-hooks/exhaustive-deps'를 작성을 하는 것이 맞을까요? A Complete Guide to useEffect (https://overreacted.io/a-complete-guide-to-useeffect)에 의존성 배열에 대한 그 답이 기술되어 있습니다. useEffect는 변할 수 있는 값에 대해서 그 변화를 감지하고 effect를 실행시키기 위해 존재하기 때문입니다. 리액트에게 의존성으로 거짓말을 하는 것은 당장은 소스코드를 의도한 것에 맞춰서 동작하도록 할 수 있지만 동작 방식에 혼란을 줄 뿐 아니라 궁극적인 시스템 유지보수에 대해서 블로커로 작용하게 됩니다. 해당 글에 맞춰서 수정을 하게 되면 다음과 같게 할 수 있습니다.
const apiCallService = new ApiCallService();
const getThumbnail: string = (target: string) => {
if (target.includes('weekly: ')) {
return target.toUpperCase();
} else {
return target;
}
};
const myComponent: React.FC = () => {
const [clickNumber, setClickNumber] = useState<number>(0);
const [title, setTitle] = useState<string>('initialTitle');
useEffect(() => {
setClickNumber(curValue => curValue += 1);
apiCallService.getTitle().then(response => {
setTitle(getThumbnail(response));
});
}, []);
const myFunction: void = () => {
console.log('function called');
setClickNumber(clickNumber + 1);
};
return (
<>
<button onClick={myFunction}>
BUTTON {title}
</button>
<span>
{clickNumber}
</span>
</>
);
};
위의 코드처럼 Component Scope밖에서 선언하는 것으로 불변성을 보장하는 방식을 통해서 apiCallService, getThumbnail 함수를 의존성 배열에서 제외할 수 있습니다. 그리고 변할 수 있는 clickNumber에 count를 하나 올리는 것이 아니라 현재 값을 통해서 증가할 수 있도록 함수를 선언하는 것으로 의존성 배열을 비울 수 있습니다. 작성자의 의도에 맞게, 그리고 리액트에게 의존성 배열을 거짓 없이 알려주는 것 모두를 충족하게 됩니다.
const apiCallService = new ApiCallService();
const myComponent: React.FC = () => {
const [clickNumber, setClickNumber] = useState<number>(0);
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
setClickNumber(curValue => curValue += 1);
apiCallService.getTitle().then(response => {
dispatch({
type: MyActionType.GET_TITLE,
title: response,
});
});
}, []);
const myFunction: void = () => {
console.log('function called');
setClickNumber(clickNumber + 1);
};
return (
<>
<button onClick={myFunction}>
BUTTON {state.title}
</button>
<span>
{clickNumber}
</span>
</>
);
};
컴포넌트 내부에서 상태에 대해서 특정 처리를 하게 되는 함수는 컴포넌트 외부에서 선언해야 의존성 배열을 피할 수 있습니다. 상태들을 관리하는 것에 대해서 가독성과 통합성을 가지기 위해 이러한 부분들을 useReducer를 통해서 리듀서 함수를 작성하고 해당 부분을 사용할 수 있습니다. 리듀서 함수는 불변성을 보장하기 때문에 의존성 배열에 영향을 미치지 않으며 관리 포인트가 줄어드는 효과를 가지게 됩니다.
※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.
※ React Hooks #3 - Hooks(useContext)에서 useReducer와 함께 global state를 사용하는 방식을 다뤄보겠습니다.
'React > Hooks' 카테고리의 다른 글
React Hooks #5 - Hooks(useRef) (0) | 2021.09.24 |
---|---|
React Hooks #4 - Hook Flow (0) | 2021.09.22 |
React Hooks #3 - Hooks(useContext) (0) | 2021.08.28 |
React Hooks #1 - Motivation (0) | 2021.07.04 |