React의 Hooks?
hooks는 React 16.8부터 React에 요소로 새로 추가되었다. 클래스형의 컴포넌트 고유 기능인 상태(state)관리와 라이프 사이클 관리 기능을 함수형 컴포넌트에서도 쓸 수 있도록 해주는 함수들을 총칭한다.
Hooks라고... 불리는 이유...
숙어인 Hook in의 뜻인 "어떻게든 손에 넣다"에서 온 것으로 추측된다. hooks를 사용하면 함수형 컴포넌트를 개발하는 개발자의 손에도 클래스형 컴포넌트에서 쓸 수 있는 기능이 쥐어지기 때문이다. (낚시할 때 그 훅이라고 생각해도 좋을 듯)
클래스형 컴포넌트
자바스크립트의 this는 다른 언어의 this와 좀 다른 의미로 쓰인다. 자바스크립트의 this는 런타임에 결정된다. 컨텍스트에 따라 this가 바뀐다는 장점도 있지만 이런 자유성이 바인딩 문제로 이어지기도 한다. 이 블로그의 this 포스팅
자바스크립트 상속 클래스에서는 super()를 반드시 호출해야 한다. 이런 규칙에 따라 클래스형 컴포넌트에서도 super(props)를 호출해야한다.
위와 같은 클래스형 컴포넌트의 단점때문에 [함수형 컴포넌트면서 state와 라이프 사이클 메서드 관리 기능 추가]를 고안하면서 Hook의 개념이 등장하게 된다. 가장 대표적인 예가 `useState`와 `useEffect`다.
useState
가장 간단하게 `+` 버튼을 누르면 1씩 증가하는 컴포넌트를 구현해보겠다.
export default function CountPlus(){
const [count, setCount] = useState(0);
function buttonPlus(){
setCount(count + 1);
}
return(
<>
<div>버튼을 클릭해보세요.</div>
<button type="button" onClick={buttonPlus}> + </button>
</>
);
}
만약 `+` 버튼을 누른다면, buttonPlus 함수가 실행됐을 것이다. setCount(count+1)이라는 구문은 어떤 의미일까?
우리가 useState를 축소해서 들여다보자면, const[count, setCount]를 const [현재 상태, 상태 갱신 함수]로 볼 수 있을 것이다.
function useState(초기값) {
let 상태변수 = 초기값;
const 상태갱신함수 = function (새로운상태변수) {
상태변수 = 새로운상태변수
}
return [상태변수, 상태갱신함수]
}
useState 호출이 다 끝나고 ... 컴포넌트가 새롭게 렌더링 된다.
즉 다시 말해 초기 상태 변수를 상태 변수 함수를 이용해 새로운 상태 변수로 바꾼다는 것이다. 그리고 useState 호출이 끝났다면 컴포넌트가 새롭게 렌더링 된다. 그래서 우리가 기존의 count = 0 에서 count = 1이 된 것을 볼 수 있는 것이다.
지금은 count가 +1 되는 상황만 설정했다. 그런데 실제 어플리케이션이 서버와 통신하거나 꼭 통신하지 않아도 다양한 조건이 필요할 때마다 위의 코드와 같이 일일히 상태 변수를 써줘야 할까? 물론 이런 상황을 대비하기 위해 useReducer라는 리액트 내장 Hook이 존재한다.
useReducer
useReducer는 reducer를 사용해 컴포넌트의 상태 로직을 관리하는 데 사용한다.
import {useReducer} from 'react';
function reducer(stat,action){
// 여기서 조건 작성
}
function Component(){
const [state, dispatch] = useReducer(reducer, {count : 0});
}
[state, dispatch]를 먼저 살펴보자. state는 말 그대로 상태다. dispatch는 reducer가 어떠한 조건으로 동작하도록 정의하는 것이다. 즉, 상태를 다른 값으로 업데이트하고 리렌터링을 발생시키는 함수다. useReducer의 두번째 매개변수는 초기값을 의미한다.
우리가 실제로 웹 어플리케이션을 만들 때, 서버로 요청을 보내고 응답을 받는다. 하지만 요청을 보냄과 동시에 응답을 받지는 못한다. 시간이 얼마나 더 걸릴지 양에 따라 달라질 수도 있고, 로직에 따라 달라질 수도 있으니.
만약 그런 상황에서 응답이 오기 전까지 기다리지 않고 출력해버린다면, 자바스크립트는 오류를 발생시키지 않는다. 그저 undefined가 출력된다. (그래서 비동기를 사용하는 것이다)
RESTful API의 예를 빌려 GET 요청을 했다고 가정하자. 발생할 수 있는 상황이 몇가지가 될까? GET의 요청 시작, 성공, 실패, 상태 등... 다양한 상태를 예상해볼 수 있다. 꼭 성공/실패로만 나뉘지는 않는다.
그렇기 때문에, 하나의 상태를 다양하게 변경해야 하거나 상태 변경이 잦을 때, 단순한 상태 갱신 함수로는 사용이 번거로울 때 사용한다. 아까 useState를 사용했던 count 예제를 useReducer로 다시 살펴보자.
import { useReducer } from "react"; //useReducer를 import한다.
function countReducer(state, action) { //Reducer 함수를 정의한다. 상태와 action
switch (action.type) { //action의 타입에 따라 반환을 다르게 정의
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
export default function Page() {
const [state, dispatch] = useReducer(countReducer, { count: 0 }); //useReducer를 정의
const reducerCount = state.count; //reducerCount를 useReducer의 두번째 인자로 사용
return (
<div>
<div>
<h2>useReducer</h2>
<p>Count: {reducerCount}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button> //+ 버튼을 누르면 action의 type을 increment로 정의
<button onClick={() => dispatch({ type: "decrement" })}>-</button> //- 버튼을 누르면 action의 type을 decrement로 정의
</div>
</div>
);
}
짤막하게 주석을 남겼지만 간단하게 설명하자면 +/- 버튼 각각을 눌렀을 때 다르게 나타나는 상태를 countReducer로 하나의 함수에 정의한 것이다. 이렇게 정의하면 useState가 여러개인 것보다 훨씬 간략하고 직관적이다. 지금은 +/-로 두개의 예시만 보았지만... 후에는 GET/POST/PUT/DELETE 등 요청 마다 발생할 수 있는 여러가지 상황을 정의해야 할 필요가 있을 때 유용하게 사용할 수 있을 것이다.
내가 이해하기 쉬우려고 한 가지를 뺐다.
const [state, dispatch] = useReducer(reducer, inintial, init?)
useReducer에 init이라는 새로운 매개변수가 생겼다. 이는 선택사항으로, 초기값을 반환하는 초기화 함수다. 초기화 함수를 전달하지 않은 경우 초기값은 initial로 설정된다.
리액트는 초기값을 한 번 저장하고 다음 렌더부터는 초기값을 무시한다.
function initFunc(name){}
function TempFunc({name}){
const [state, dispatch] = useReducer(reducer, initFunc(name));
}
만약 이렇게 작성했다고 가정하자. 세번째 매개변수를 제외함으로써, dispatch가 한 번 이뤄질 때마다 initFunc을 계속 호출하게 될 것이다. 아직은 복잡한 로직이 없지만 초기화 함수의 내용이 길어지거나 복잡해지는 경우 최적화에서 멀어질 수도 있다.
function initFunc(name){}
function TempFunc({name}){
const [state, dispatch] = useReducer(reducer, name, initFunc);
}
그래서 useReducer의 세번째 인자로 초기화 함수를 전달한다. 이렇게 하면 초기값을 계산하면서 initFunc의 인자로 name이 사용하게 된다. 초기화 값에 초기화 함수를 직접 전달하지 않고 초기화 함수와 초기화 값을 함께 전달하면 초기화 이후 초기 상태가 다시 생성되지 않는다. 만약 초기 상태를 계산하는데 필요한 정보가 없으면 두번째 인자로 null을 전달하면 된다.
Context
공식문서를 참고하기 때문에... 해석상 약간의 어색함이 있을 수 있다.
useReducer와 함께 자주 등장하는 것이 useContext다. 하지만 그 전에 Context에 대해서 알아볼 필요가 있을 것 같다.
우리는 보통 부모 컴포넌트에서 자식 컴포넌트로 prop을 전달한다.
하지만 여러 컴포넌트들에게 전달해줘야 한다면? 이 과정이 매우 번거로울 수 있다. 부모 컴포넌트와 자식 컴포넌트가 다이렉트(?)로 연결되었다면 좋겠지만, 중간에 많은 컴포넌트가 존재할 것이다.
또한, 많은 컴포넌트들이 비슷한 props을 원하거나 그 구조가 깊어질 수 있으며, 그런 상황에서 부모 컴포넌트가 멀리 떨어져 있을 수도 있다.
lifting state up
만약 App.jsx에 동시에 상태 변경을 필요로 하는 컴포넌트 2개가 있다고 생각해보자. 이들은 아마도 두개의 컴포넌트 상태를 동시에 부모 컴포넌트로 prop을 통해 전달해줘야 할 것이다. 이를 lifting state up(상태 올리기?)라고 부르는데, 구조를 깊게 사용하다보면 'prop driling'이 발생한다.
만약 이런 구조로 사용하게 된다면, 컴포넌트 마다 prop을 전달하면서 분명 실수가 생길 것이다. 공식문서에서 말하길, 'teleport'가 있으면 좋을 것이라고 한다. 즉, Context는 이런 prop drililng없이 필요한 컴포넌트에게 바로 전달할 수 있게 해주는 것이다.
Context를 사용하면 부모 컴포넌트가 아래 전체 트리에 데이터를 전달할 수 있다.
아래 3단계에 따라 Context를 사용할 수 있다고 한다.
1. Creat a Context: LeveContext라고 부를 수 있다.
2. Use that Context from the component that need the data: 데이터가 필요한 컴포넌트에서 컨텍스트를 사용한다.
3. Provide that Context from the component that specifies the data: 데이터 구성 컴포넌트에서 컨텍스트를 제공한다.
레벨에 따라 글자 크기를 달리하는 예제를 간단하게 들어보겠다.
1. Create a Context
import {createContext} from 'react';
export const LevelContext = createContext(1);
2. Use that Context from the compoentn that need the data
import {LevelContext} from './LevelContext.js'
//기존의 컴포넌트
export default function Heading({level, children}){}
//바뀐 컴포넌트
export default function Heading({children}){
const level = useContext(LevelContext); //Context를 import한다
}
3. Provide that Context from the component that specifies the data
Context를 사용하기 전, 아래와 같이 section마다 level을 주어 글자 크기를 조정했다.
export default function Page() {
return (
<Section level={1}>
...
<Section level={2}>
...
<Section level={3}>
...
이제는 Section.jsx를 아래와 같이 사용할 수 있다. (Heading.js에서 level마다 return하는 <h>태그가 다르다.)
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';
export default function Section({ children }) {
const level = useContext(LevelContext);
return (
<section className="section">
<LevelContext.Provider value={level + 1}>
{children}
</LevelContext.Provider>
</section>
);
}
후에 App.js에서 LevelContext.Provider를 이용해 LevelContext 데이터에 접근한다. Section을 선언할 때마다 1씩 증가할테니, 점점 글자가 작아질 것이다.
최종적으로 아래와 같이 사용할 수 있게된다.
import Heading from './Heading.js';
import Section from './Section.js';
export default function ProfilePage() {
return (
<Section>
<Heading>My Profile</Heading>
<Post
title="Hello traveller!"
body="Read about my adventures."
/>
<AllPosts />
</Section>
);
}
function AllPosts() {
return (
<Section>
<Heading>Posts</Heading>
<RecentPosts />
</Section>
);
}
function RecentPosts() {
return (
<Section>
<Heading>Recent Posts</Heading>
<Post
title="Flavors of Lisbon"
body="...those pastéis de nata!"
/>
<Post
title="Buenos Aires in the rhythm of tango"
body="I loved it!"
/>
</Section>
);
}
function Post({ title, body }) {
return (
<Section isFancy={true}>
<Heading>
{title}
</Heading>
<p><i>{body}</i></p>
</Section>
);
}
<Section>이 트리 모두에서 사용하게 되므로, <Heading>을 어디에서나 사용할 수 있는 것이다. 어디에 삽입하든 1씩 증가하게 되어 <h1>,<h2>..와 같이 증가하게 될 것이다.
편의상 예제를 많이 생략했으므로 이곳을 참조하는 것이 좋다.
예제를 한가지 더 들어보겠다.
+ 버튼을 누르면 값이 1 올라가고, - 버튼을 누르면 값이 1 내려가는 예제다. 이를 useReducer와 useContext를 사용해 구현해본다.
import { useContext } from "react";
import { useReducer } from "react";
import { createContext } from "react";
const initialState = 0;
const StateContext = createContext(initialState);
// Java의 Enum처럼 사용
export const ActionTypes = {
ADD: "ADD",
SUBTRACT: "SUBTRACT",
};
export const reducer = (state, action) => {
console.log('reducer state', state)
console.log('reducer action', action)
switch (action.type) {
case ActionTypes.ADD:
return ++state;
case ActionTypes.SUBTRACT:
return --state;
default:
return state;
}
};
export default function Page() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={{ state, dispatch }}>
<Counter></Counter>
</StateContext.Provider>
);
}
function ChildCounter() {
const { state, dispatch } = useContext(StateContext);
return <div>
<h2>useReducer</h2>
<p>Count: {state}</p>
<button onClick={() => dispatch({ type: ActionTypes.ADD })}>+</button>
<button onClick={() => dispatch({ type: ActionTypes.SUBTRACT })}>
-
</button>
</div>
}
function Counter() {
return (
<div>
<ChildCounter></ChildCounter>
</div>
);
}
먼저 이에 대해서 구조를 파악해보겠다.
Page는 화면을 나타낼 것이다. 그런데 Context를 하나 만들어 useReducer의 state와 dispatch를 전달한다. 최종적으로는 ChildCounter에서 화면을 구성해주는데, 여기까지 Reducer가 전달되려면 Counter를 거쳐야한다. 하지만 Counter에 prop으로 Reducer를 전달하지 않고 Context를 썼고, 이를 ChildCounter에서 prop으로 전달 받아 prop driling 없이 원하는 대로 구현할 수 있다.
어쨌든 useContext와 useReducer를 알아봤다. 원래는 이렇게 상태관리를 하지 않고 `Redux`라는 라이브러리를 사용한다. Redux는 Context API를 포함하며, Reducer를 모두 지원해준다. Redux가 없을 경우엔 이번 포스팅과 같이 useContext와 useReducer를 구현하면 된다. 원래는 Redux까지 알아보고 예제를 Reudx toolkit으로 바꿔보려 했는데...
다른 hook들도 공부해야해서 (선택과 집중의 논리에 의해...) 여기까지 알아보고 예제에 대한 자세한 포스팅에서 다시 다뤄보겠다.
완벽하진 않지만 일단
끝!
'Programming > React' 카테고리의 다른 글
[React] React의 탄생 이유 (1) | 2024.04.11 |
---|