React로 준비? 좀 하고 개발하기 - 1
React Hooks
Hooks are functions that let you “hook into” React state and lifecycle features from function components
공식문서에 의한 hooks에 대한 정의입니다. *함수 컴포넌트에서 React state와 생명주기 기능(lifecycle features)을 "연동(hook into)"할 수 있게 해주는 함수로 번역된다. 생명주기함수와 hooks를 둘다 써본 지금에서야 저 정의에서 와닿는 부분은, hooks로 생명주기 함수들을 재정의을 해뒀는데, 정의를 못하는 것도 있고, 어떤건 좀 더 디테일하게 세부적으로 해둘 수 있는 것도 존재한다는 정도이다.
사실 처음 hooks를 접했을 때 부터 클래스형 컴포넌트로 구현(개발)이 전부다 가능한데, 왜 이렇게 해뒀을까? 라는 고민을 많이 했던 것 같다.. 그 이유부터 좀 알아보려 합니다. (https://ko.reactjs.org/docs/hooks-intro.html#motivation 공식문서를 토대로 작성하였습니다.)
1. 컴포넌트 사이에서 상태 로직을 재사용하기 어렵습니다.
어느정도 클래스형 컴포넌트를 사용해본적이 있다면 여러 컴포넌트로 래핑되는 케이스들을 만날 수 있다. 이런 경우 공식문서에서는 래핑지옥이라고 하는데 이 래핑 지옥에서는 상태와 관련된 로직을 재사용하기 힘들다라고 한다.
hooks를 사용하기 전에는 이말이 이해가 잘 안되었는데, 사용해보니 알게되었다..
좀 쉽게 생각해보자면
- api로 data를 불러오기
- store에 저장하기
- store에 있는 data를 가져오는 로직을 hook으로 분리
- 래핑된 컴포넌트에서도 각각 호출
위 방식으로 한다고하면 고차원의 컴포넌트에서도 분리된 hook(하나의 로직)으로 props 없이 data 관리가 가능하다. (react-query까지 쓴다면 캐시로 관리할수도 있다)
이게 무슨 x소리인가.. 싶은데 고차원(뎁스가 깊은)으로 랩핑된 컴포넌트 경우 props으로 루트컴포넌트에서 최하위 컴포넌트로 데이터를 전달한다고 한다면, 주입된 data가 변경될 경우 props 변경으로 최하위 컴포넌트까지 모두 rerender가 일어난다. 어차피 rerender된다면 구지 props로 data 전달 시켜서 props 의존성 키울 필요가 없다라고 본 것 같다. props 의존성이 커질수록, 유지보수로 인한 주입된 data의 변형이 일어날수록 건드려야하는 component의 수가 많아지니.. 말그대로 래핑지옥이 될 수 있다.
예시를 한번 만들어볼까 했는데 생각보다 어려워서 포기했다.. props로 전달받은 data를 생명주기함수에서 활용하는 비즈니스 로직이 제법 있을 때 효용성이 생기는데 간단한 예시로서는 오히려 전달하기가 힘들것 같아 예시는 생략하도록 했다.
2. 복잡한 컴포넌트들은 이해하기 어렵습니다.
이것도 사실 제목을 보고선 무슨 소리인가 싶었는데, 내용을 읽어보니 제법 이해가 갔다. 하나의 컴포넌트 내 상위컴포넌트에서 api 콜을 통해 하위컴포넌트에도 필요한 api콜을 한번에 하게되는 경우가 많은데, 이럴 경우 상위컴포넌트에서의 componentDidMount
(렌더직후), componentDidUpdate
(값변경), componentWillUnmount
(파괴직전) 생명주기함수에 과도하게 많은 로직들이 들어가있는 경우가 많다. api에서 받아온 데이터 전+후처리..를 해주는 과정이 될텐데.. 이 경우 제법 유지보수가 많이 힘들다. 이런걸 hooks로 분리해서 조금 로직을 세분화 시킬수가 있다. 특히 componentDidUpdate
는 많은 값(state, props)중 하나라도 변경이 일어나게되면 발생하는데, useEffect()
에서는 변하는 특정 값을 지정해서 구독할 수 있다. 이런 생명주기함수에 의존?하는 로직들을 hooks로 나눠서 복잡성을 떨어뜨리고 컴포넌트를 좀더 나눌 수 있게되는 장점이 있다. (아마 이 장점 때문에 hooks가 나오자마자 각광을 받았던게 아닐까 싶네요.)
3. Class는 사람과 기계를 혼동시킵니다.
이 부분은, js개발자라 OOP에 대한 부담이 힘들수도 있고, this의 사용이 일반적인 사용과 다르기 때문에 혼동을 줄 수 있는점을 지적하는 것 같다. (이 부분은 크게 공감하기 힘들었던게 사실 별로 힘들지 않았는데...) 그래도 내부적으로 class형이 줄 수 있는 문제(코드 최적화, 핫 리로딩을 깨지기 쉽게 만드는 문제)들을 우회하고자 hooks를 토대로한 개발 방법을 제시 한 것으로 보입니다.
정리
위와 같은 이유들이 있지만, class형 방식을 지원하는 것을 react는 버리지 않을 것이라고한다. 하지만 페이스북도 더이상 추가로 class형 컴포넌트를 만들지 않고 새로운 코드에는 hooks를 위주로 사용한다고 할거니 성능개선이나 여러 개선사항들이 class형보다는 hooks위주로 적용될 것 같다. 이 점을 충분히 고려하여 나 역시도 앞으로는 hooks style로 개발을 해볼까한다..
React Hooks 파보기
0. hooks rule
https://ko.reactjs.org/docs/hooks-rules.html#gatsby-focus-wrapper 페이지를 참조하여 제 방식으로 해석해봤습니다.
1. 최상위(at the Top Level)에서만 Hook을 호출해야 합니다
아마 처음에 hooks를 사용해보다보면 hooks rules 관련된 error를 볼 때가 있다. 그 중 자주 봤던 에러는 위와 같은 에러다. 이건 react가 정한 규칙인데, react 내부에서 hooks가 불리는 타이밍을 기억하고 있다. 공식문서의 설명을 보면 이해가 잘 되지는 않긴한데, hooks가 조건문으로 감싸져 있어 상태에 따라 불릴수도 있고 안불릴수도 있다면 안불린 상태가 되었을 때 react 내부에서 기억하는 hooks 타이밍과 어긋나 에러를 유발하는 것으로 보인다.
이걸 왜 막지? 라고 생각할 수 있지만 여러개의 hooks가 항상 동일하게 호출되는 것을 보장하기 위해서 위와 같은 규칙이 있는 것으로 생각된다. prop, state 변경에 의한 render가 결국 hooks에 의존하고 있으니 hooks가 조건적으로 불리는건 조건적으로 render가 되었다 안되었다 할 수 있는 상황이 올 수있어서 막아둔 것으로 보인다(추측). 조건문이 필요하다면 hook 바깥이 아닌 hook 안에 선언하여 사용하면 된다.
2. 오직 React 함수 내에서 Hook을 호출해야 합니다
일반적인 js 함수내에서는 호출을 못하게끔 되있다. 이것도 추측이지만 아마 render와 관련있을거라고 생각한다. 함수형 컴포넌트 최상단 혹은 custom hooks내에서만 hook을 사용할 수 있다.
위 두가지는 규칙이라 반드시 지켜야한다. 지키지 않으면.. 돌아가지가 않는다 ㅎ..
1. useState()
function Counter() {
const [number, setNumber] = useState(0);
const increase = () => {
setNumber(number + 1);
}
const decrease = () => {
setNumber(number - 1);
}
return (
<div>
<h1>{number}</h1>
<button onClick={increase}>+1</button>
<button onClick={decrease}>-1</button>
</div>
);
}
class형에 사용하던 setState와 비슷한 맥락이라고 보면된다. 컴포넌트 내에서 사용할 state에 대한 이름과, state를 변경할 함수를 상단에 정의해주고 사용하면 된다.
2. useEffect()
useEffect(() => {
console.log('컴포넌트가 화면에 나타남');
return () => {
console.log('컴포넌트가 화면에서 사라짐');
};
}, []);
기본적으로 두번째 파라미터값이 변할때, 첫번째 파라미터로 들어간 함수가 실행된다. 위 예처럼 두번째 파라미터가 빈배열일 경우, 최초 컴포넌트 렌더링시에 호출된다. useEffect(() => {}, [])
두번째 파라미터가 배열인 이유는 의존성있는 값을 여러개 둘 수 있다. useEffect() 덕분에 componentDidUpdate()
가 많이 세분화시킬 수 있게 되었다. 기존에는 state, props중 일부만 변경되도 생명주기함수가 호출되어 일일이 변경점을 체크해줘야했는데, 지정한 특정 값에만 의존성이 생기게되어 이 점이 매우 편리해졌다.
그리고 첫번째 파라미터안에서 함수를 반환하게 될경우 반환한 함수는 unMount시 호출된다. 일전에 포스팅한 각각 componentDidMount
(첫번째 파라미터), componentWillUnMount
(첫번째 파라미터가 리턴한 함수), componentDidUpdate
(두번째 파라미터의 의존성에 따라)의 역할을 한다.
위 세 생명주기 함수가 하나의 hook으로 모두 표현가능하다는 점은 매우 매우 간편해졌다고 본다.
3. useContext()
export const ParentContext = createContext({idx: 0});
const Parent = () => {
const [idx, setIdx] = useState(456);
return (
<ParentContext.Provider value={{idx}}>
<Child idx={123}></Child>
</ParentContext.Provider>
);
};
const Child = (props) => {
return (
<div>
<GrandChild idx={props.idx}></GrandChild>
</div>
);
};
import {ParentContext} from "./Parent";
const GrandChild = (props): ReactElement | null => {
const {idx} = useContext(ParentContext)
return (
<div>
{idx}
{props.idx}
</div>
);
};
props drilling을 막기위한 전역 props 생성을 해주는 hook이다. component의 depth가 너무 커지면 props로 데이터를 주고받을 때 유지보수 하기가 생각보다 쉽지 않다. 그럴땐 상단 component에서 createContext()통해서 하위 컴포넌트에서도 쉽게 접근가능하게 context를 하나 생성하여 접근용이성을 취할 수 있다.
여기서 조심해야할 점은 ~Context.Provider가 제공하는 value가 변경되게 되면 하위의 모든 컴포넌트들이 재렌더링 된다. 이점을 유의해서 value로 전달하는 값을 쓰는 component들은 memoization을 고려해줘야한다.
4. useReducer()
function reducer(state, action) {
switch (action.type) {
case 'INCREASE':
return state + 1;
case 'DECREASE':
return state - 1;
}
}
function Counter() {
const [number, dispatch] = useReducer(reducer, 0);
const increase = () => {
dispatch({ type: 'INCREASE' });
}
const decrease = () => {
dispatch({ type: 'DECREASE' });
}
return (
<div>
<h1>{number}</h1>
<button onClick={increase}>+1</button>
<button onClick={decrease}>-1</button>
</div>
);
}
useState와 동일한 기능을 한다. state를 관리하는 녀석이다. 얼핏보면 왜쓰나 싶긴한데 위 예처럼 state가 간단한 상태라면 useState로 쓰는게 훨씬 직관적이고 간편하다. 다만 state가 매우 복잡한형태의 object라면? 그 state를 가지고 조작하는 로직도 많을것이다. 그럴 경우 state를 조작하는 logic만 reducer형태로 빼서 로직을 분리할 수 있게된다. state가 복잡할만큼 component가 커지게된 설계의 잘못도 있겠지만.. 그럴 수 밖에 없는 상황두 있을테니 그런 상황에서는 쓰면 좋을 것 같다.
5. useMemo()
const Parent = () => {
const [firstValue, setFirstValue] = useState(123);
const [secondValue, setSecondValue] = useState(new Date().getTime());
const clickFirstValueSet = () => {
setFirstValue(new Date().getTime())
}
const clickSecondValueSet = () => {
setSecondValue(new Date().getTime())
}
return (
<div>
<button onClick={() => {clickFirstValueSet()}}>firstValueSet</button>
<button onClick={() => {clickSecondValueSet()}}>secondValueSet</button>
<Child idx={firstValue} type={'first'}></Child>
<Child idx={secondValue} type={'second'}></Child>
</div>
);
};
const Child = (props): => {
console.log('render : ', props.type)
const displayValue = useMemo(() => {
console.log('display : ', props.type)
return props.idx
}, [props.idx])
return (
<div>
{displayValue}
</div>
);
};
useMemo를 활용할만한 예제를 만드느라 머리를 제법 굴려봤는데 마땅한게 잘 없는 것 같다. 각각의 버튼을 누르면 어떤 버튼을 눌러도 render : props.type
은 찍히지만, render시킬 displayValue
를 계산하는 로직은 버튼을 누른 컴포넌트의 타입만 돌아가게 된다.
Parent component의 state가 변경되어 Parent내 Child component 두개가 모두 재 렌더링 되지만, 렌더링에 쓰이는 값을 memoization해 두었기 때문에 rendering 시키는데 필요한 displayValue
계산은 버튼을 누른 타입에 한해서만 돌아간다. 이 경우 연산량을 줄일 수 있어서 연산이 많은 로직 같은 경우에는 제법 이득을 볼 수 있다.
React.memo()
그러나 사실 위 같은 경우는 reRender를 안시키는게 제일 좋다. useMemo가 나온김에 Component를 memoization하는 것도 알아두자.
const Child = React.memo((props): => {
console.log('render : ', props.type)
const displayValue = () => {
console.log('display : ', props.type)
return props.idx
}
return (
<div>
{displayValue}
</div>
);
});
매우 심플하다. Component 자체를 React.memo()로 감싸면 된다. 이 경우 감싼 Component로 전달되는 props를 얕은비교를 하게 되는데 얕은 비교를 통해 차이가 없으면 아예 렌더링을 안 시킨다. 여기서 얕은 비교가 중요한데, 원시타입의 props는 값만 비교하지만 object type은 참조값을 비교한다. 여기서 불변성을 항상 고려해줘야한다. (나중에 이부분은 따로 다루도록 하겠다.) 무튼 값은 그대로인데 참조값이 변할 경우가 생기는데, 이 경우 React.memo(() => {}, equalCheckFunction(prev, next))
두번째 파라미터로 함수를 추가하여 파라미터로 받는 prev, next 상태의 props를 비교하여 return true/false를 통해 re render를 시킬지말지 정의할 수 있다.
React에서는 reRender를 피할수 있으면 피하는게 좋다. 생각보다 서비스 레벨로 가면 component의 depth가 높기 때문에 state/props 변경으로 인한 rerender가 엄청 많이 발생한다. 이 reRender를 피하는 방법엔 몇가지가 있지만 개인적으로는 React.memo
가 가장 직관적이고 효율적이지 않나 생각한다.
6. useCallback()
const Parent = () => {
console.log('parent')
const [firstValue, setFirstValue] = useState(123);
const [secondValue, setSecondValue] = useState(new Date().getTime());
const clickFirstValueSet = useCallback(() => {
setFirstValue(new Date().getTime())
}, [firstValue])
const clickSecondValueSet = useCallback(() => {
setSecondValue(new Date().getTime())
}, [secondValue])
return (
<div>
<Child clickEvent={clickFirstValueSet} type={'first'}></Child>
<Child clickEvent={clickSecondValueSet} type={'second'}></Child>
</div>
);
};
const Child = React.memo((props): => {
console.log('render : ', props.type)
return (
<div>
<button onClick={props.clickEvent}>value set</button>
</div>
);
});
useCallback()은 함수를 memoization하는 hook이다. 예제를 보면 return값이 없는 함수인데도 왜 memoization을 하지? 라고 생각할 수 있습니다(memoization을 할 필요가 있나?). 먼저 Parent component에서 value set을 해주는 함수들을 모두 useCallback으로 감싸고 있습니다. 이건 Parent component가 reRender될 때, 두번째 파라미터로 받고있는 의존성 값이 변하지 않았다면 clickFirstValueSet()
, clickSecondValueSet()
method들을 재정의하지 않기 위함입니다. clickFirstValueSet()
만 실행되었다고 한다면, secondValue
값이 변하지 않았을테니 clickSecondValueSet()
은 재정의 되지 않으니 참조값이 변하지 않는다. 그러면 이 메소드들을 각각 props로 받게되는 Child Component를 React.memo
로 component를 memoization해둔다면 어떻게 될까? Child Component에서 props로 받게되는 clickEvent
의 값이 같으므로 두번째 Child Component는 rerender가 되지 않는다.
여기서 핵심적으로 봐야할 부분은 두가지입니다.
- useCallback()으로 method를 감싸게되면 의존성값(두번째 파라미터)이 변하지 않는 이상 rerender되더라도 method 재정의가 일어나지 않는다.
- method를 props로 받고있고 React.memo로 component가 memoization된 하위 component가 있다면, method 재정의를 막아서 props를 불변상태로 만드니 rerender를 막을 수 있다.
7. useRef()
const UseRefTest = (): ReactElement | null => {
const [msg, setMsg] = useState("initState");
const inputRef = useRef<HTMLInputElement>(null)
return (
<div>
<input ref={inputRef} onChange={(e) => {setMsg(e.target.value)}} value={msg} type="text"/>
<button onClick={() => {
if (inputRef.current) {
inputRef.current.focus();
}
}}>test</button>
</div>
);
};
useRef는 위 예제처럼 dom을 선택하기 위한 hook으로 알려져있다. 근데 공식문서를 살펴보다보니 이게 단순히 dom 선택자가 아닌, 어떤 가변 값을 유지
시키는데도 사용한다고 한다. 이게 무슨말인가 싶어 이것 저것 찾아보았더니, 개인적인 해석으로는 react에 영향(값 변경에 의한 reRender, 생명주기 함수)을 받지않는 순수 js로 정의하는 변수로 사용할 수 있을거라는 생각이 들었다. 쉽게 얘기하면 var
type의 변수를 전역적으로 선언하는 것이다.
const Parent = (): ReactElement | null => {
console.log('parent')
const [firstValue, setFirstValue] = useState(123);
const clickFirstValueSet = useCallback(() => {
setFirstValue(new Date().getTime())
}, [firstValue])
return (
<div>
<Child clickEvent={clickSecondValueSet} type={'second'}></Child>
<UseRefTest idx={firstValue}></UseRefTest>
</div>
);
};
const UseRefTest = (props: Props): ReactElement | null => {
const [counter, setCounter] = useState(0);
const useRefCounter = useRef<number>(0);
console.log('reRender')
return (
<div>
<div>
<button onClick={() => {
useRefCounter.current = useRefCounter.current + 1;
}}>test
</button>
{useRefCounter.current}
</div>
<div>
<button onClick={() => {
setCounter(counter + 1);
}}>
counter
</button>
{counter}
</div>
</div>
);
};
위 예제를 보면, 아주 쉽게 이해할 수 있다. counter
값은 일반적인 state, useRefCounter
값은 useRef로 생성한 객체이다. 각 버튼들은 해당 값들을 +1씩 증가시키는 버튼인데 버튼을 눌러보면, 놀랍게도 useRef로 생성한 값은 re render가 되지 않는다. 그 후에 counter
를 수정하는 버튼을 눌러보면 useRef로 생성한 값이 변해있는 것을 볼수있다. react에서는 컴포넌트가 상태를 가지기 위해서는 state 혹은 store에 저장을 해뒀어야하는데 이 두 방식은 값이 변경될 때 마다 re render가 되는 문제가 있다. 이부분을 피하기 위해 useRef로 값을 정의한다면, 값의 변경은 기록되지만 re render가 되지 않는다. 이부분은 포스팅을 위해 공부하다 알게되었지만.. 실무에서도 제법 유용할 것으로 보인다. axios, hls 같은 option의 수정은 있지만 re render를 시키고 싶지 않을 때 매우 유용할 듯 하다.
8. useImperativeHandle()
const Parent = () => {
const testRef = useRef<any>(null);
return (
<div>
<button onClick={() => {testRef.current.change()}}>111</button>
<UseRefTest ref={testRef}></UseRefTest>
</div>
);
};
const UseRefTest = forwardRef((prop, ref) => {
const [counter, setCounter] = useState(0);
useImperativeHandle(ref, () => {
return {
change: () => {
setCounter(counter + 1)
}
}
})
return (
<div>
<div>
{counter}
</div>
</div>
);
});
useImperativeHandle()
구글번역기 돌리면 피할수없는.. handle을 위한 hook이 되는 것 같다. Parent component에서 Child component의 함수를 실행하기 위해 Parent component에서 useRef로 공용으로 사용할 purejs 변수를 만들어두고 Child component에서 생성한 ref에 함수를 추가하는 식이 되는 것 같다. 부모 컴포넌트에서 자식 컴포넌트의 함수를 사용하게 되는 경우가 빈번하게 많이 있지는 않지만.. 서비스를 개발하다보면 한두번 씩은 왔던 것 같으니 알아두면 유용할 것 같다.
9. useLayoutEffect()
const NameTest = (props) => {
const [name, setName] = useState('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@');
useEffect(() => {
setName('KADAMON')
}, [])
return (<div>
네임 은 {name} 입니다
</div>)
};
const NameTest = (prop) => {
const [name, setName] = useState('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@');
useLayoutEffect(() => {
setName('test')
}, [])
return (<div>
네임 은 {name} 입니다
</div>)
};
이건 현업에서 디테일을 위해 만들어진 hook으로 보인다. 위 예제로 구동 시켜보면 새로고침을 누르다보면 @@@@@~
이가 잠깐 떴다가 KADAMON
으로 변경된다. 구글링해보면 useLayoutEffect()
가 불리는 타이밍을 그림으로 잘 표현된게 있는데 저작권때문에 혹시나하여 기록만 해둔다.
- Mount - run lazy initializers
- Update - Render
- Update - React updates DOM
- Update - Cleanup LayoutEffects
- Update - Run LayoutEffects
- Update - Browser paints screen
- Update - Cleanup Effects
- Update - Run Effects
useEffect()
경우 Browser paints screen 후에 동작을 하게되기 때문에 @@@@@@@@@@@@@@@@@
이 간헐(제법 자주)적으로 잠깐 보일 수 있다. 정확하게 paint가 동작하는 방식을 몰라 보일때도 있고, 안 보일때도 있는데 useLayeoutEffect()를 사용하게 되면 paint 전에 hook call이 발생하고 hook call이 끝나야 paint 되기 때문에 무조건 kadamon
으로 보인다. 디테일한 렌더링까지 신경써줘야할 때 쓰면 좋은 hook으로 보인다.
10. useDebugValue()
const useCustomHook = () => {
useDebugValue('debug - test');
useEffect(() => {
console.log('123')
}, [])
}
const Child = React.memo((props: Props): ReactElement | null => {
// console.log('render : ', props.type)
useCustomHook();
return (
<div>
<button onClick={props.clickEvent}>value set</button>
</div>
);
});
custom hook의 호출 포인트를 찾기 위한 용도로 가끔씩 쓰인다고 한다. 공식문서에서도 모든 hook에는 달아줄 필요는 없다고 한다. 개발자 도구에서 custom hook의 이름을 부여하여 확인하는 용도로 쓰이는듯 하다.
p.s. 이 외에도 몇가지 hook이 더 있지만 주로 쓸것 같은 녀석들로만 일단 정리해봤다 남은 hook들은 천천히 알아보도록 하자..
'Front_End > React' 카테고리의 다른 글
React로 준비? 좀 하고 개발하기 - 0 : React Lifecycle (0) | 2022.11.09 |
---|---|
React로 무작정 개발하기 - 2 (0) | 2019.12.03 |
React로 무작정 개발하기 - 1 (0) | 2019.11.23 |
React로 무작정 개발하기 - 0 (준비) (0) | 2019.11.10 |