useState
는 가장 기본적인 Hook
으로서, 함수형 컴포넌트에서도 가변적인 상태를 지니고 있을 수 있게 해준다. 만약에 함수형 컴포넌트에서 상태를 관리해야 되는 일이 발생한다면 이 Hook
을 사용하면 된다.
const [name, setName] = useState('');
const [nickname, setNickname] = useState('');
useEffect
는 리액트 컴포넌트가 렌더링 될 때마다 특정 작업을 수행하도록 설정 할 수 있는 Hook
이다. 클래스형 컴포넌트의 componentDidMount
와 componentDidUpdate
를 합친 형태로 보아도 무방.
만약 useEffect
에서 설정한 함수가 컴포넌트가 화면에 가장 처음 렌더링 될 때만 실행되고 업데이트 할 경우에는 실행 할 필요가 없는 경우엔 함수의 두번째 파라미터로 비어있는 배열을 넣어주면 된다.
useEffect(() => {
console.log('마운트 될 때만 실행됩니다.');
}, []);
useEffect
를 사용 할 때 특정 값이 변경이 될 때만 호출하게 하고 싶을 경우도 있을 것이다. 만약 클래스형 컴포넌트라면 다음과 같을것이다.
componentDidUpdate(prevProps, prevState) {
if (prevProps.value !== this.props.value) {
doSomething();
}
}
위 코드에서는 props
안에 들어있는 value
값이 바뀔 때에만 특정 작업을 수행하도록 하였다. 만약 이러한 작업을 useEffect
에서 해야한다면 어떻게 해야 할까?
바로, useEffect
의 두번째 파라미터로 전달되는 배열 안에 검사하고 싶은 값을 넣어라.
useEffect(() => {
console.log(name);
}, [name]);
useEffect
는 기본적으로 렌더링 되고난 직후마다 실행되며, 두번째 파라미터 배열에 무엇을 넣느냐에 따라 실행되는 조건이 달라진다.
만약 컴포넌트가 언마운트되기 전이나, 업데이트 되기 직전에 어떠한 작업을 수행하고 싶다면 useEffect
에서 뒷정리(cleanup) 함수를 반환해주어야 한다.
useEffect(() => {
console.log('effect');
console.log(name);
return () => {
console.log('cleanup');
console.log(name);
};
});
뒷정리 함수가 호출 될 때에는 업데이트 되기 직전의 값을 보여주고 있다.
만약에, 오직 언마운트 될 때만 뒷정리 함수를 호출하고 싶으시다면 useEffect
함수의 두번째 파라미터에 비어있는 배열을 넣으면 된다.
이 Hook 을 사용하면 함수형 컴포넌트에서 Context 를 보다 더 쉽게 사용 할 수 있습니다.
import React, { createContext, useContext } from 'react';
const ThemeContext = createContext('black');
const ContextSample = () => {
const theme = useContext(ThemeContext);
const style = {
width: '24px',
height: '24px',
background: theme,
};
return <div style={style} />;
};
export default ContextSample;
useReducer
는 useState
보다 컴포넌트에서 더 다양한 상황에 따라 다양한 상태를 다른 값으로 업데이트해주고 싶을 때 사용하는 Hook
이다. 리듀서(reducer) 라는 개념은 조만간 다루게 될 Redux
를 배우게 될 때 더 자세히 알아보게 된다. 만약 현재 이 내용이 어렵다고 느껴지신다면 추후 리덕스를 공부하고나서 나서 이 내용을 다시 한번 확인해보자.
리듀서는 현재 상태와, 업데이트를 위해 필요한 정보를 담은 액션(action
) 값을 전달 받아 새로운 상태를 반환하는 함수다. 리듀서 함수에서 새로운 상태를 만들 때는 꼭 불변성을 지켜주어야 한다.
function reducer(state, action) {
return { ... }; // 불변성을 지키면서 업데이트한 새로운 상태를 반환합니다
}
{
type: 'INCREMENT',
// 다른 값들이 필요하다면, 추가적으로 들어감
}
Redux
에서는 액션 객체에는 어떤 액션인지 알려주는 type
필드가 꼭 있어야 하지만, useReducer
에서 사용하는 액션 객체는 꼭 type
를 지니고 있을 필요가 없다. 심지어, 객체가 아니라 문자열이나, 숫자여도 상관이 없다.
import React, { useReducer } from 'react';
function reducer(state, action) {
// action.type 에 따라 다른 작업 수행
switch (action.type) {
case 'INCREMENT':
return { value: state.value + 1 };
case 'DECREMENT':
return { value: state.value - 1 };
default:
// 아무것도 해당되지 않을 때 기존 상태 반환
return state;
}
}
const Counter = () => {
const [state, dispatch] = useReducer(reducer, { value: 0 });
return (
<div>
<p>
현재 카운터 값은 <b>{state.value}</b> 입니다.
</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
</div>
);
};
export default Counter;
useReducer
의 첫번째 파라미터는 리듀서 함수, 그리고 두번째 파라미터는 해당 리듀서의 기본 값을 넣어준다. 이 Hook
을 사용 했을 때에는 state
값과 dispatch
함수를 받아오게 되는데, 여기서 state
는 현재 가르키고 있는 상태고, dispatch
는 액션을 발생시키는 함수다. dispatch
(action) 와 같은 형태로, 함수 안에 파라미터로 액션 값을 넣어주면 리듀서 함수가 호출되는 구조다.
useReducer
을 사용했을 때의 가장 큰 장점은 컴포넌트 업데이트 로직을 컴포넌트 바깥으로 빼낼 수 있다는 점이다.
useMemo
를 사용하면 함수형 컴포넌트 내부에서 발생하는 연산을 최적화 할 수 있다. 먼저 리스트에 숫자들을 추가하면 해당 숫자들의 평균을 나타내는 함수형 컴포넌트를 작성해보자.
import React, { useState } from 'react';
const getAverage = (numbers) => {
console.log('평균값 계산중..');
if (numbers.length === 0) return 0;
const sum = numbers.reduce((a, b) => a + b);
return sum / numbers.length;
};
const Average = () => {
const [list, setList] = useState([]);
const [number, setNumber] = useState('');
const onChange = (e) => {
setNumber(e.target.value);
};
const onInsert = (e) => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
};
return (
<div>
<input value={number} onChange={onChange} />
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => (
<li key={index}>{value}</li>
))}
</ul>
<div>
<b>평균 값:</b> {getAverage(list)}
</div>
</div>
);
};
export default Average;
평균 값은 잘 보여지고 있는데, 숫자를 등록할 때뿐만 아니라 인풋 내용이 수정 될 때도 우리가 만든 getAverage
함수가 호출되고 있는것을 확인 할 수 있다. 인풋 내용이 바뀔 땐 평균 값을 다시 계산 할 필요가 없다.
useMemo Hook
을 사용하면 이러한 작업을 최적화 할 수 있다. 렌더링 하는 과정에서 특정 값이 바뀌었을 때만 연산을 실행하고 만약에 원하는 값이 바뀐 것이 아니라면 이전에 연산했던 결과를 다시 사용하는 방식.
import React, { useState, useMemo } from 'react';
const getAverage = (numbers) => {
console.log('평균값 계산중..');
if (numbers.length === 0) return 0;
const sum = numbers.reduce((a, b) => a + b);
return sum / numbers.length;
};
const Average = () => {
const [list, setList] = useState([]);
const [number, setNumber] = useState('');
const onChange = (e) => {
setNumber(e.target.value);
};
const onInsert = (e) => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
};
const avg = useMemo(() => getAverage(list), [list]);
return (
<div>
<input value={number} onChange={onChange} />
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => (
<li key={index}>{value}</li>
))}
</ul>
<div>
<b>평균 값:</b> {avg}
</div>
</div>
);
};
export default Average;
useCallback
은 useMemo
와 상당히 비슷한 함수다. 주로 렌더링 성능을 최적화해야 하는 상황에서 사용하는데, 이 Hook을 사용하면 이벤트 핸들러 함수를 필요할 때만 생성 할 수 있다.
우리가 방금 구현한 Average
컴포넌트를 보면, onChange
와 onInsert
라는 함수를 선언해주었다. 이렇게 선언을 하게 되면 컴포넌트가 리렌더링 될 때마다 이 함수들이 새로 생성된다. 대부분의 경우에는 이러한 방식이 문제가 되지 않지만, 컴포넌트의 렌더링이 자주 발생하거나, 렌더링 해야 할 컴포넌트의 개수가 많아진다면 이 부분을 최적화 해주시는 것이 좋다.
한번 useCallback
을 사용하여 최적화를 해보자.
import React, { useState, useMemo, useCallback } from 'react';
const getAverage = (numbers) => {
console.log('평균값 계산중..');
if (numbers.length === 0) return 0;
const sum = numbers.reduce((a, b) => a + b);
return sum / numbers.length;
};
const Average = () => {
const [list, setList] = useState([]);
const [number, setNumber] = useState('');
const onChange = useCallback((e) => {
setNumber(e.target.value);
}, []); // 컴포넌트가 처음 렌더링 될 때만 함수 생성
const onInsert = useCallback(
(e) => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
},
[number, list]
); // number 혹은 list 가 바뀌었을 때만 함수 생성
const avg = useMemo(() => getAverage(list), [list]);
return (
<div>
<input value={number} onChange={onChange} />
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => (
<li key={index}>{value}</li>
))}
</ul>
<div>
<b>평균값:</b> {avg}
</div>
</div>
);
};
export default Average;
useCallback
의 첫번째 파라미터에는 우리가 생성해주고 싶은 함수를 넣어주고, 두번째 파라미터에는 배열을 넣어주면 되는데 이 배열에는 어떤 값이 바뀌었을 때 함수를 새로 생성해주어야 하는지 명시해주어야 한다.
만약에 onChange
처럼 비어있는 배열을 넣게 되면 컴포넌트가 렌더링 될 때 단 한번만 함수가 생성되며, onInsert
처럼 배열 안에 number
와 list
를 넣게 되면 인풋 내용이 바뀌거나 새로운 항목이 추가 될 때마다 함수가 생성된다.
함수 내부에서 기존의 상태 값을 의존해야 할 때는 꼭 두번째 파라미터 안에 포함을 시켜주어야 한다. 예를 들어서 onChange
의 경우엔 기존의 값을 조회하는 일은 없고 바로 설정만 하기 때문에 배열이 비어있어도 상관이 없지만 onInsert
는 기존의 number
와 list
를 조회해서 nextList
를 생성하기 때문에 배열 안에 number
와 list
를 꼭 넣어주어야 한다.
useCallback
은 결국 useMemo
에서 함수를 반환하는 상황에서 더 편하게 사용 할 수 있는 Hook 이다. 숫자, 문자열, 객체 처럼 일반 값을 재사용하기 위해서는 useMemo
를, 그리고 함수를 재사용 하기 위해서는 useCallback
을 사용하자.
useRef Hook
은 함수형 컴포넌트에서 ref
를 쉽게 사용 할 수 있게 해준다. Average
컴포넌트에서 등록 버튼을 눌렀을 때 포커스가 인풋 쪽으로 넘어가도록 코드를 작성해보자.
import React, { useState, useMemo, useRef } from 'react';
const getAverage = (numbers) => {
console.log('평균값 계산중..');
if (numbers.length === 0) return 0;
const sum = numbers.reduce((a, b) => a + b);
return sum / numbers.length;
};
const Average = () => {
const [list, setList] = useState([]);
const [number, setNumber] = useState('');
const inputEl = useRef(null);
const onChange = useCallback((e) => {
setNumber(e.target.value);
}, []); // 컴포넌트가 처음 렌더링 될 때만 함수 생성
const onInsert = useCallback(
(e) => {
const nextList = list.concat(parseInt(number));
setList(nextList);
setNumber('');
inputEl.current.focus();
},
[number, list]
); // number 혹은 list 가 바뀌었을 때만 함수 생성
const avg = useMemo(() => getAverage(list), [list]);
return (
<div>
<input value={number} onChange={onChange} ref={inputEl} />
<button onClick={onInsert}>등록</button>
<ul>
{list.map((value, index) => (
<li key={index}>{value}</li>
))}
</ul>
<div>
<b>평균 값:</b> {avg}
</div>
</div>
);
};
export default Average;
useRef
를 사용하여 ref
를 설정하면, useRef
를 통해 만든 객체 안의 current
값이 실제 엘리먼트를 가르키게 된다.
function useState(initialValue) {
var _val = initialValue; // _val은 useState에 의해 만들어진 지역 변수입니다.
// state는 내부 함수이자 클로저입니다.
function state() {
return _val; // state()는 부모 함수에 정의된 _val을 참조합니다.
}
function setState(newVal) {
_val = newVal; // _val를 노출하지 않고 _val를 변경합니다.
}
return [state, setState]; // 외부에서 사용하기 위해 함수들을 노출
}
var [foo, setFoo] = useState(0); // 배열 구조분해 사용
console.log(foo()); // 0 출력 - 위에서 넘긴 initialValue
setFoo(1); // useState의 스코프 내부에 있는 _val를 변경합니다.
console.log(foo()); // 1 출력 - 동일한 호출하지만 새로운 initialValue
React
의 useState
Hook의 아주 기본적인 형태의 복제본을 만들어 보았습니다. 이 함수에는 state
와 setState
라는 두 개의 내부 함수가 있습니다. state
는 상단에 정의된 지역 변수 _val
를 반환하고, setState
는 전달 된 매개 변수 (예: newVal
)로 지역 변수를 설정한다.
여기서 state
는 게터 함수로 구현하여 이상적이지는 않지만, 이는 조금 뒤에 수정하겠다. 중요한 것은 foo
와 setFoo
를 사용하여 내부 변수 _val
에 접근하고 조작(일명 “덮어쓰기”) 할 수 있다는 것이다. 이 둘은 useState
의 스코프에 대한 접근 권한을 가지고 있고, 이러한 참조를 클로저라고 한다. React
와 다른 프레임워크의 맥락에서 보면 이것은 ‘상태’라고 할 수 있고 실제로도 그렇다.
function Counter() {
const [count, setCount] = useState(0); // 위에 useState와 같음
return {
click: () => setCount(count() + 1),
render: () => console.log('render:', { count: count() }),
};
}
const C = Counter();
C.render(); // render: { count: 0 }
C.click();
C.render(); // render: { count: 1 }
여기서는 DOM
으로 렌더링하는 대신 상태를 console.log
로 출력하도록 하겠다. 또한 카운터에 프로그램적인 API를 노출하여 이벤트 핸들러를 붙이는 대신 스크립트로 실행할 수 있도록 하였다. 이러한 설계를 통해 컴포넌트 렌더링을 시뮬레이션하고 사용자 작업에 반응할 수 있다.
이 코드가 동작할 때 상태(state
)에 접근하기 위해 게터 함수를 호출하는 것은 실제 React.useState
hook의 API와 다르다. 이것도 수정해보자.
실제 React API
와 동일하게 만들려면 state
가 함수가 아닌 변수여야 한다. 단순히 _val
을 함수로 감싸지 않고 노출하면 버그가 발생한다.
function useState(initialValue) {
var _val = initialValue;
// state() 함수 없음
function setState(newVal) {
_val = newVal;
}
return [_val, setState]; // _val를 그대로 노출
}
var [foo, setFoo] = useState(0);
console.log(foo); // 함수 호출 할 필요 없이 0 출력
setFoo(1); // useState의 스코프 내부에 있는 _val를 변경합니다.
console.log(foo); // 0 출력 - 헐!!
이것은 오래된 클로저 문제의 한 형태이다. useState
의 결과에서 foo
를 비구조화할 때 초기 useState
호출에서 _val
을 참조하고… 다시는 변경되지 않는다! 이는 우리가 의도한 바와 다르다. 일반적으로 현재 상태를 반영하기 위해 컴포넌트의 상태가 필요하지만, 이는 함수 호출이 아닌 단지 변수일 뿐이다! 이 두 가지 목표는 절대 같이 갈 수 없는 것처럼 보인다.
이 useState
의 난제는… 우리의 클로저를 또 다른 클로저의 내부로 이동시켜서 해결할 수 있다!
작은 React
복제본을 만들기 위해 모듈 패턴을 사용하였다. React
와 마찬가지로 컴포넌트의 상태를 추적한다. (이 예제에서는 _val
로 한 컴포넌트의 상태만 추적). 이 설계를 통해 MyReact
는 함수형 컴포넌트를 “render
”할 수 있고, 매번 항상 올바른 클로저를 통해 내부의 _val
값을 할당할 수 있다.
const MyReact = (function () {
let _val; // 모듈 스코프 안에 state를 잡아놓습니다.
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useState(initialValue) {
_val = _val || initialValue; // 매 실행마다 새로 할당됩니다.
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();
// 상위 모듈을 통한 함수형 컴포넌트
function Counter() {
const [count, setCount] = MyReact.useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log('render:', { count }),
};
}
let App;
App = MyReact.render(Counter); // render: { count: 0 }
App.click();
App = MyReact.render(Counter); // render: { count: 1 }
지금까지 첫 번째 기본 React Hook
인 useState
에 대해 살펴보았다. 다음으로 가장 중요한 Hook
은 useEffect
다. setState
와 달리 useEffect
는 비동기로 실행되므로 클로저 문제가 발생할 가능성이 더 높다.
지금까지 만들어놓은 작은 React 모델에 useEffect
를 추가해보자.
의존성을 추적하기 위해(의존성이 변경될 때 useEffect
가 다시 실행되므로), 이를 추적하는 별도의 변수, _deps
를 추가하였다.
const MyReact = (function () {
let _val, _deps; // 스코프 안에서 상태와 의존성을 잡아 놓습니다.
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const hasChangedDeps = _deps
? !depArray.every((el, i) => el === _deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
_deps = depArray;
}
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();
// 사용하는 곳
function Counter() {
const [count, setCount] = MyReact.useState(0);
MyReact.useEffect(() => {
console.log('effect', count);
}, [count]);
return {
click: () => setCount(count + 1),
noop: () => setCount(count),
render: () => console.log('render', { count }),
};
}
let App;
App = MyReact.render(Counter);
// 이펙트 0
// render {count: 0}
App.click();
App = MyReact.render(Counter);
// 이펙트 1
// render {count: 1}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1}
App.click();
App = MyReact.render(Counter);
// 이펙트 2
// render {count: 2}
지금까지 useState
와 useEffect
의 기능을 그럴듯하게 갖춘 복제본을 만들었다. 하지만 둘 다 잘못 구현된 싱글톤 형태다. (각각 하나 이상이 존재하면 버그가 발생gks다.)
좀 더 흥미로운 것들을 구현하려면 (그리고 오래된 클로저에 대한 마지막 예제를 보여드리려면) 여러 개의 상태(state)와 효과(effect)를 받을 수 있는 일반적인 형태로 구현되어야 한다. 다행히 Rudi Yardley의 글처럼, React Hooks는 마법이 아니라 배열일 뿐이다. hooks 배열을 추가해보자. 또한 _val
과 _deps
가 서로 겹쳐지지 않기 때문에 hooks 배열에 하나로 합칠 수 있다.
const MyReact = (function () {
let hooks = [],
currentHook = 0; // Hook 배열과 반복자(iterator)!
return {
render(Component) {
const Comp = Component(); // 이펙트들이 실행된다.
Comp.render();
currentHook = 0; // 다음 렌더를 위해 초기화
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = hooks[currentHook]; // type: array | undefined
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
hooks[currentHook] = depArray;
}
currentHook++; // 이 Hook에 대한 작업 완료
},
useState(initialValue) {
hooks[currentHook] = hooks[currentHook] || initialValue; // type: any
const setStateHookIndex = currentHook; // setState의 클로저를 위해!
const setState = (newState) => (hooks[setStateHookIndex] = newState);
return [hooks[currentHook++], setState];
},
};
})();
여기서 setStateHookIndex
를 사용하는 것이 아무 의미 없는 것처럼 보이지만, setState
에서 currentHook
변수가 덮어 씌워지는 것을 방지하는 데 사용된다! 해당 부분을 제거하면, 덮어 씌워진 currentHook
은 오래된 클로저 문제를 일으켜 setState
를 다시 호출했을 때 동작하지 않는다.
function Counter() {
const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState('foo'); // 두번 째 상태 Hook!
MyReact.useEffect(() => {
console.log('effect', count, text);
}, [count, text]);
return {
click: () => setCount(count + 1),
type: (txt) => setText(txt),
noop: () => setCount(count),
render: () => console.log('render', { count, text }),
};
}
let App;
App = MyReact.render(Counter);
// 이펙트 0 foo
// render {count: 0, text: 'foo'}
App.click();
App = MyReact.render(Counter);
// 이펙트 1 foo
// render {count: 1, text: 'foo'}
App.type('bar');
App = MyReact.render(Counter);
// 이펙트 1 bar
// render {count: 1, text: 'bar'}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1, text: 'bar'}
App.click();
App = MyReact.render(Counter);
// 이펙트 2 bar
// render {count: 2, text: 'bar'}
위와 같이, 기본적인 개념은 Hook의 배열과 각 Hook이 호출될 때 증가하고 컴포넌트가 렌더링 될 때 초기화되는 인덱스를 갖는 것이다.
function Component() {
const [text, setText] = useSplitURL('www.netlify.com');
return {
type: (txt) => setText(txt),
render: () => console.log({ text }),
};
}
function useSplitURL(str) {
const [text, setText] = MyReact.useState(str);
const masked = text.split('.');
return [masked, setText];
}
let App;
App = MyReact.render(Component);
// { text: [ 'www', 'netlify', 'com' ] }
App.type('www.reactjs.org');
App = MyReact.render(Component);
// { text: [ 'www', 'reactjs', 'org' ] }}
다음의 글을 참고했습니다.