컴포넌트 상태
컴포넌트 상태(state) 개념
컴포넌트의 상태(state)란 리액트 컴포넌트 내부에서 관리하는 데이터로, 사용자와의 상호작용에 따라 변경될 수 있는 값을 의미합니다.
리액트의 상태란 값이 바뀔 때 마다 화면이 리렌더링되는 변수를 말한다. 리액트 컴포넌트 내부에서 let, const로 선언된 변수는 값을 바꿔도 리액트가 인식하지 못해 컴포넌트가 리렌더링 되지 않는다. 즉 변수 값을 바꿔도 화면에 반영이 되지 않는다. 이렇듯 변수 값에 따라 화면이 바뀌어야할 때는 리액트의 상태를 사용한다.
**Q. 만약 컴포넌트 리렌더링을 수동으로 시켜주는 메서드가 있다면 let, const 변수를 사용할 수 있을까? A. 사용할 수 없다. ** 우선 컴포넌트는 함수이기 때문에, 컴포넌트가 리렌더링 되면 let, const 변수는 이전 상태를 저장하지 않고 다시 초기값으로 되돌아와 값이 변경되지 않는다(let count = 0; 다시 실행). 그리고 리렌더링을 수동으로 요청할 경우, 해당 영역이 아닌 모든 컴포넌트 영역이 리렌더링되어 비효율적이다.
Q. 모든 변수를 리액트의 상태로 처리하는 것은 어떨까? A. 권장하지 않는다. 상태는 바뀔 때 마다 컴포넌트의 리렌더링이 일어난다(해당 컴포넌트 + 자식 컴포넌트). 값에 따라 화면이 바뀔 필요 없는 변수는 상태로 처리할 필요가 없다. 단, 리렌더링을 유발하지 않고 값이 유지되어야 하는 데이터의 경우 useRef 훅을 이용한다.
훅(hook) 개념
훅(hook)이란 함수형 컴포넌트에서 상태(state)와 생명주기(lifecycle)를 쉽게 관리할 수 있도록 도와주는, 리액트에서 제공하는 특별한 함수입니다.
리액트 앱에서 함수형 컴포넌트를 사용하는 경우, 상태를 관리할 때 훅을 사용한다.
상태 훅은 함수형 컴포넌트에서 상태를 쉽게 관리할 수 있도록 도와주는 함수이다.
대표적인 상태 훅으로는 useState, useReducer가 있다.
useState
useState훅은[ 이전_상태(값), 상태_변경_함수 ]형태의 배열을 반환하는 함수입니다. 이 배열을 구조 분해 할당하면 컴포넌트 내부에서 상태를 저장하고 변경할 수 있는 2개의 변수를 선언할 수 있습니다.
// useState 기본 형식
const [ state, setState ] = useState<Type>(initialState)
/*
state: 상태 변수
setState: 상태 변경 함수
Type: 상태의 타입, ts가 자체적으로 타입 추론을 하므로 생략 가능
initialState: 상태 변수의 초깃값
*/
useState를 호출하면 상태(state)와 상태 변경 함수(setState)를 각각 변수로 받아 저장할 수 있다.
상태 값이 변경될 때마다 리액트는 상태가 있는 컴포넌트를 자동으로 리렌더링한다.
상태 값은 상태 변경 함수를 이용하여 변경한다.
// useState 사용 예시
import { useState } from 'react'
export default function App()
{
const [ count, setCount ] = useState(0);
return(
<>
<p>count: { count }</p>
<button onClick={ () => { setCount(count => count + 1) } }>증가</button>
</>
)
}
useState 사용시 주의 사항
useState와 같은 리액트 훅은 반드시 함수형 컴포넌트 내부의 최상단에 위치해야한다.
즉 for문이나, 다른 함수 내부에 위치해있으면 훅의 원칙에 어긋나게된다.
리액트에서는 내부적으로 상태를 훅의 호출 순서대로 저장하므로 훅의 호출 순서가 바뀌면 저장된 상태와 훅이 잘못 매칭되게 된다.
따라서 렌더링 시점에 따라 훅의 호출 순서가 바뀌지 않도록 규칙으로 제한하는 것이다.
또한 상태를 업데이트 할 때 count = 5; 처럼 상태에 바로 값을 대입해서는 안된다.
상태에 바로 값을 대입하면 리액트가 인식하지 못해 컴포넌트의 리렌더링이 일어나지 않기 때문이다.
상태(state)를 업데이트 할 때는 항상 상태 변경 함수(setState)를 사용해야한다.
setState를 사용하는 방법은 두 가지가 있다.
하나는 setCount(5); 처럼 직접 값을 대입하는 방법으로 이전 값에 상관없이 해당 값으로 초기화할 때 주로 사용한다.
다른 하나는 표현식을 대입하는 방법이다.
이 때 표현식을 기본 형태와, 콜백 함수 형태 두 가지로 대입할 수 있는데, 콜백 함수 형태로 사용하는 것을 추천한다.
왜냐하면 상태 업데이트는 비동기적으로 처리되므로, 의도치 않게 계산이 되는 경우가 있기 때문이다.
리액트는 여러 상태 변경을 즉시 처리 하지 않고 비동기적으로 처리해 렌더링이 끝난 뒤 한 번에 모아서 적용합니다. 이 방식을 일괄 업데이트(batch update)라고 하며, 불필요한 리렌더링을 줄여 성능을 최적화하는 방식입니다.
리액트 상태 훅은 스케줄링과 배치를 이용해 컴포넌트를 효율적으로 리렌더링한다.
스케줄링은 setState 함수가 호출될 때 즉시 업데이트 하는 것이 아니라, 상태 업데이트를 처리하도록 일정을 잡는 과정이다. setState가 호출되면 리액트는 컴포넌트를 업데이트가 필요한 컴포넌트 목록에 추가한 뒤, 리렌더링 작업을 이벤트 루프 대기열에 추가한다.
배치는 여러 개의 상태 업데이트 요청을 하나로 묶어 한 번의 리렌더링으로 처리하는 과정이다.
즉 스케줄링과 배치를 이용하면 상태 업데이트 요청이 여러개여도 화면 리렌더링은 단 한 번만 일어나 성능이 최적화 된다.
// 의도치 않은 계산 예시(기본 형태)
import { useState } from 'react'
export default function App()
{
const [ count, setCount ] = useState(0);
const increaseCount = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
return(
<>
<p>count: { count }</p>
<button onClick={increaseCount}>3 증가</button>
</>
)
}
위 예시에서 버튼을 눌러도 count의 값은 1이다. 왜냐하면 상태 훅에서 배치 과정을 거칠 때, 각각의 계산식에서 렌더링 시점의 count는 0이였으므로 0+1, 0+1, 0+1이 세 번 동작 해 결국 count는 1만 더해졌기 때문이다.
// 의도한 계산 예시(콜백 형태)
import { useState } from 'react'
export default function App()
{
const [ count, setCount ] = useState(0);
const increaseCount = () => {
setCount(count => count + 1);
setCount(count => count + 1);
setCount(count => count + 1);
}
return(
<>
<p>count: { count }</p>
<button onClick={increaseCount}>3 증가</button>
</>
)
}
콜백 함수 형태로 사용하면 count는 이전 호출의 결과를 기반으로 순차적으로 계산하므로, 0+1, 1+1, 2+1이 동작 해 count가 의도한대로 3이 나오게 된다. 즉, 배치 처리가 되더라도 최종적인 계산 결과가 올바르게 누적된다.
useState의 type과 initial_state를 지정할 때 ts 제네릭 관련하여 참고할 점이 있다.
만약 초기값이 null이고, 이후 저장되는 값이 문자열인 경우, 즉 초기값의 타입과 저장되는 값의 타입이 서로 다른 경우는 유니언 타입을 이용하여 type을 여러개 지정해주어야 차후 오류가 나지 않는다.
const [ name, setName ] = useState<null | string>(null);
const [ age, setAge ] useState<null | number>(null);
사용해야할 상태 변수가 많으면 각각의 변수를 모두 useState로 선언해야해서 번거로울 수 있는데, 이 때 변수를 하나의 객체로 모아 useState를 사용하는 방법이 있다.
interface UserValue {
name: null | string,
age: null | number
}
const [ userValues, setUserValues ] = useState<UserValue>({
name: null,
age: null
})
하나의 객체로 useState를 사용할 때는 setState를 사용할 때 전개 연산자를 활용해 값을 복사하여 새로운 값으로 지정하는 것을 잊지 말자.
useState사용시 주의 사항 요약
- 반드시 함수형 컴포넌트 내부의 최상단에 위치해야함
- 상태를 업데이트 할 때는 항상 상태 변경 함수를 사용
- setState를 사용할 때는 콜백 함수 형태로 사용하는 것을 추천
- type과 initial_state을 지정할 때 초기값의 타입과 저장되는 값의 타입이 서로 다른 경우 type 여러개 지정
- useState를 객체로 사용할 경우 setState 사용 시 전개 연산자 사용(기존 값 참조 X, 새 값으로 추가)
useReducer
useReducer훅은 리액트에서 상태를 관리하는 또 다른 방법으로, 이전 상태와 액션에 따라 새로운 상태를 반환하는 방식입니다. 특히 상태 변경 로직이 복잡하거나 업데이트해야 하는 경우가 많으면 useState 훅보다 더 적합할 수 있습니다.
useReducer는 useState와 사용법과 주의사항이 거의 비슷하다. 다만 useState보다 더 복잡한 상태 관리를 위해 만들어졌다. 여러 하위 값들이 밀접하게 연관되어 있어 상태 업데이트 로직이 복잡해질 때, 혹은 다음 상태가 이전 상태에 의존적일 때 useReducer를 사용하면 로직을 컴포넌트 외부로 분리하여 가독성과 테스트 용이성을 높일 수 있다. useState와 마찬가지로 두 개의 값이 있는 배열을 반환하며, 구조 할당을 통해 상태와 dispatch 함수를 얻을 수 있다.
// useReducer 기본 형식
const [ state, dispatch ] = useReducer<Type>(reducer, initialState)
/*
dispatch: action 발생 함수, reducer 함수에 action을 전달하는 함수
reducer: dispatch를 호출했을 때 호출되는 사용자 정의 함수.
*/
리듀서 함수(reducer function)는 상태를 변경하는 함수로, 이전 상태와 액션을 받아서 새로운 상태를 반환합니다.
리듀서 함수는 사용자 정의 함수로, 매개변수로 상태(state)와 action을 가진다. 주로 내부에서 switch 문으로 액션 타입에 따른 분기를 나눈다. 반드시 하나의 값은 반환해야하며(default 에서도), 반환 하는 값은 기존 값이 아닌 새로운 값이여야한다(전개 연산자). 리액트는 반환된 값이 기존 state와 참조가 다를 때만 리렌더링을 수행하기 때문에 불변성 원칙을 지켜야하기 때문이다.
액션(action)은 리듀서 함수에서 어떤 상태 변경을 수행할지 결정하기 위해 참조하는 값입니다.
액션은 리듀서 함수에게 전달하는 매개변수 중 하나로, 주로 객체로 사용하여 type과 payload를 가진다.
{ type: 'ACTION_TYPE', payload: 데이터 }
// useReducer 사용 예시
import { useReducer } from 'react';
function countReducer(state: number, action: { type: string }) {
switch(action.type) {
case 'INCREASE':
return state + 1;
case 'DECREASE':
return state - 1;
case 'RESET':
return 0;
default:
return state;
}
}
export default function App() {
const [ count, countDispatch ] = useReducer(countReducer, 0);
return(
<>
<p>count: { count }</p>
<button onClick={ () => { countDispatch({ type: 'INCREASE' }) } }>증가</button>
<button onClick={ () => { countDispatch({ type: 'DECREASE' }) } }>감소</button>
<button onClick={ () => { countDispatch({ type: 'RESET' }) } }>초기화</button>
</>
);
}
리듀서 함수는 별도의 파일로 분리해서 관리할 수 있다.
내부에 JSX가 없으므로 .ts 확장자로 분리하며, src/reducer 경로에 위치하는 것이 관례이다.
위 코드를 예로들면 countReducer.ts로 파일을 분리하여 작성할 수 있다.
사용할 때는 App.tsx에서 import 문으로 불러와 사용한다.
리액트의 상태 관리 패턴
실무에서 컴포넌트 간 상태를 공유할 때는 8부모 컴포넌트에서 useState 또는 useReducer를 선언하여 상태와 상태 관리 함수를 생성한 후, props를 통해 자식 컴포넌트로 전달한다.
이 때 상태 관리 함수는 바로 전달해도 문제가 없지만, 부모 컴포넌트에서 한 번 랩핑해서 자식 컴포넌트로 전달하는 것을 추천한다(캡슐화).
또한 자식 컴포넌트가 여러개일 때, 컴포넌트끼리 하나의 상태를 공유해야하는 경우가 생긴다. 이 때 자식 컴포넌트에 있는 상태를 부모 컴포넌트로 옮기는 것을 상태 끌어올리기라고 한다. 상태 끌어올리기를 한 후에도 마찬가지로 자식 컴포넌트들은 부모 컴포넌트로 부터 props로 상태와 상태 관리 함수를 전달받는다.
// 상태 끌어올리기 예시
import { useState } from 'react'
import CountDisplay from './components/CountDisplay.tsx'
import CountButton from './components/CountButton.tsx'
export default function App() {
const [ count, setCount ] = useState(0);
const increase = () => { setCount(count => count + 1) };
const decrease = () => { setCount(count => count - 1) };
return(
<>
<CountDisplay count={ count } />
<CountButton increase={ increase } decrease={ decrease } />
</>
);
}
상태를 공유하기 위해 부모를 거쳐 여러 단계 아래의 자식으로 Props를 전달하는 현상을 Prop Drilling이라고 한다. 이 단계가 너무 깊어지면 코드 추적이 어려워지므로, 이때는 컴포넌트 합성이나, Context API, **상태 관리 라이브러리(Zustand, Redux 등)**를 고려한다.
상태 디버깅 팁
크롬의 리액트 개발자 도구를 통해 console.log 없이 상태 값을 확인할 수 있다.