기존의 프론트엔드 프레임워트(Ember,Backbone, Angular JS 등)은 자바스크립트의 특정 값이 바뀌면 특정 DOM의 속성이 바뀌도록 연결해 업데이트 작업을 간소화하는 방식으로 구현되었다.
그러나 리액트의 경우 조금 다른 발상에서 만들어졌다. 리액트는 어떠한 상태가 바뀌었을 때, 그 상태에 따라 DOM을 어떻게 업데이트할 지 규칙을 정하는 것이 아니라, 아예 다 날려버리고 처음부터 모든것을 새로 만들어서 보여준다면 어떨까?라는 아이디어에서 시작되었다.
이 경우 "어떻게 업데이트를 해야할지"에 대한 고민을 하지 않아도 되어 개발이 쉬워지지만, 동적인 UI를 보여주기 위해 모든 것을 새로 만든다는 것은 굉장히 느린 속도를 야기할 수 있다. (규모가 큰 웹 애플리케이션인 경우는 더더욱)
리액트는 이 문제를 Virtual DOM이라는 개념을 통해 해결했다.
Virtual DOM은 가상의 DOM인데, 브라우저에서 실제로 보여지는 DOM이 아니라 메모리에 가상으로 존재하는 DOM으로서 그냥 JavaScript 객체이므로 작동 성능이 실제 브라우저 DOM에서 보여주는 것 보다 훨씬 속도가 쁠다. 리액트는 상태가 업데이트되면 업데이트가 필요한 곳의 UI를 Virtual DOM을 통해 렌더링한다. 그리고 이후 효율적인 비교 알고리즘(리액트 개발팀이 만든) 통해 실제 브라우저에서 보여지고 있는 DOM과 비교한 후, 차이가 잇는 부분을 실제 DOM에 패치 시켜준다. 결과적으로 "업데이트를 어떻게 할지"에 대한 고민을 하지 않으면서, 동시에 빠른 속도도 지킬 수 있게 되었다.
ref
를 사용하고 함수형 컴포넌트에서 이를 설정하기 위해 useRef
를 사용한다.useRef
로 관리하는 변수는 값이 바뀐다고 해서 컴포넌트가 리렌더링이 되지 않는다. 리액트 컴포넌트에서의 상태는 상태를 바꾸는 함수를 호출하고 나서 그 다음 렌더링 이후로 업데이트 된 상태를 조회할 수 있는 반면, useRef
로 관리하고 있는 변수는 설정 후 바로 조회할 수 있다.해당 변수를 사용하여 다음과 같은 값을 관리할 수 있다.
setTimeout
, setInterval
을 통해 만들어진 id
App 컴포넌트에서 useRef
를 사용해 변수를 관리해보자! 용도는 우리가 앞으로 배열에 새 항목을 추가할 건데, 새 항목에서 사용할 고유 id를 관리하는 용도이다.
import React, { useRef } from 'react';
import UserList from './UserList';
function App() {
const users = [
{
id: 1,
username: 'velopert',
email: 'public.velopert@gmail.com'
},
{
id: 2,
username: 'tester',
email: 'tester@example.com'
},
{
id: 3,
username: 'liz',
email: 'liz@example.com'
}
];
const nextId = useRef(4);
const onCreate = () => {
// 나중에 구현 할 배열에 항목 추가하는 로직
// ...
nextId.current += 1;
};
return <UserList users={users} />;
}
export default App;
import React from 'react';
function User({ user }) {
return (
<div>
<b>{user.username}</b> <span>({user.email})</span>
</div>
);
}
function UserList({ users }) {
return (
<div>
{users.map(user => (
<User user={user} key={user.id} />
))}
</div>
);
}
export default UserList;
배열에 있는 항목을 제거할 때에는, 추가할때와 마찬가지로 불변성을 지켜가며 업데이트 하는 것이 중요하다. 불변성을 지키며 특정 원소를 배열에서 제거하기 위해서는 filter
배열 내장 함수를 사용하는것이 가장 편하다. 이 함수는 배열에서 특정 조건이 만족하는 원소들만 추출하여 새로운 배열을 만들어 준다.
filter
와 같은 함수를 사용하여 원소를 제거한 새로운 배열을 생성한다. filter
함수는 주어진 조건에 맞는 원소만으로 새로운 배열을 만들어주므로, 원본 배열은 변경되지 않는다. 이렇게 하면 불변성을 유지하면서 배열의 원소를 제거할 수 있다.useEffect라는 Hook을 사용하여 컴포넌트가 마운트 됐을 때 (처음 나타났을 때), 언마운트 됐을 때(사라질 때), 그리고 업데이트 될 때 (특정 props가 바뀔 때) 특정 작업을 처리하는 방법에 대해서 알아보겠습니다.
useEffect(() => {
console.log('컴포넌트가 화면에 나타남');
return () => {
console.log('컴포넌트가 화면에서 사라짐');
};
}, []);
useEffect
deps
). deps
배열을 비우게 된다면 컴포넌트가 처음 나타날 때에만 useEffect
에 등록한 함수가 호출된다.useEffect
에서 함수를 반환할 수 있는데, 이를 cleanup
함수라고 부른다. useEffect
의 뒷정리를 해준다고 이해하면 된다. deps
가 비어있는 경우 컴포넌트가 사라질때 cleanup
함수가 호출된다.props
로 받은 값을 컴포넌트의 로컬 상태로 설정deps에 특정 값을 넣게 된다면, 컴포넌트가 처음 마운트 될 때에도 호출이되고, 지정한 값이 바뀔 때에도 호출이 된다. 그리고, deps안에 특정 값이 있다면 언마운트시에도 호출되고, 값이 바뀌기 직전에도 호출이 된다.
useEffect
안에서 사용하는 상태나 props가 있다면 useEffect
의 deps에 넣어줘야 한다. 만약 useEffect
안에서 사용하는 상태나 props를 deps안에 넣지 않게 된다면 useEffect
에 등록한 함수가 실행될 때 최신 props/상태를 가르키지 않게 된다.
deps 파라미터를 생략하면 컴포넌트가 리랜더링 될 때마다 호출된다.
참고
리액트 컴포넌트는 기본적으로 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트 또한 리렌더링 된다. (바뀐 내용이 없더라도) 물론, 실제 DOM에 변화가 반영되는 것은 바뀐 내용이 있는 컴포넌트에만 해당된다. 하지만, Virtual DOM에는 모든걸 다 렌더링하고 있다.
성능 최적화를 위해 연산된 값을 useMemo
라는 Hook을 사용하여 재사용하는 방법을 알아보자!
App 컴포넌트에서 다음과 같이 countActiveUsers
라는 함수를 만들어, active
값이 true
인 사용자의 수를 세어 화면에 렌더링 해보자.
import React, { useRef, useState } from "react";
import UserList from "./UserList";
import CreateUser from "./CreateUser";
function countActiveUsers(users) {
console.log("활성 사용자 수를 세는 중...");
return users.filter((user) => user.active).length;
}
function App() {
const [inputs, setInputs] = useState({
username: "",
email: "",
});
const { username, email } = inputs;
const onChange = (e) => {
const { name, value } = e.target;
setInputs({
...inputs,
/**
* - 계산된 속성명(Computed Property Name) 문법
* 대괄호 안에 있는 표현식의 결과를 속성명으로 사용한다.
* 동적으로 속성명을 결정할 수 있어 유연한 코드 작성이 가능해진다.
*/
[name]: value,
});
};
const [users, setUsers] = useState([
{
id: 1,
username: "velopert",
email: "public.velopert@gmail.com",
active: true,
},
{
id: 2,
username: "tester",
email: "tester@example.com",
active: false,
},
{
id: 3,
username: "liz",
email: "liz@example.com",
active: false,
},
]);
const nextId = useRef(4);
const onCreate = () => {
const user = {
id: nextId.current,
username,
email,
};
setUsers(users.concat(user));
setInputs({
username: "",
email: "",
});
nextId.current += 1;
};
const onRemove = (id) => {
// user.id가 파라미터로 일치하지 않는 원소만 추출해서 새로운 배열을 만듬
// = user.id가 id인 겂을 제거함
setUsers(users.filter((user) => user.id !== id));
};
const onToggle = (id) => {
setUsers(
users.map((user) =>
/**id가 동일할 경우 user의 기존 속성은 유지하고 active만 변경 */
user.id === id ? { ...user, active: !user.active } : user
)
);
};
const count = countActiveUsers(users);
return (
<>
<CreateUser
username={username}
email={email}
onChange={onChange}
onCreate={onCreate}
/>
<UserList users={users} onRemove={onRemove} onToggle={onToggle} />
<div>활성 사용자 수 : {count}</div>
</>
);
}
export default App;
countActiveUsers
함수에서 콘솔에 메시지를 출력하도록 한 것은, 이 함수가 호출도리 때마다 우리가 알 수 있도록 하기 위함이다.이 경우 성능상 문제가 발생하게 되는데, input 값을 변경할 때 counterActiveUsers
함수가 호출된다는 점이다. 활성 사용자 측정은 users에 변화가 있을때만 이루어져야 하는데, input 값이 바뀔때에도 컴포넌트가 리렌더링 되므로 이렇게 불필요할 때에도 호출이 이뤄져 자원이 낭비되고 있다.
이런 상황에 useMemo
라는 Hook 함수를 사용해 성능을 최적화할 수 있다.
Memo는 memorized를 의미하는데, 이는 이전에 계산한 값을 재사용한다는 의미를 가지고 있다.
import React, { useRef, useState, useMemo } from "react";
// const count = countActiveUsers(users);
const count = useMemo(() => countActiveUsers(users), [users]);
useMemo
useCallback
은 useMemo
와 비슷한 Hook이다. useMemo
는 특정 결과값을 재사용할 때 사용하는 반면, useCallback
은 특정 함수를 새로 만들지 않고 재사용하고 싶을 때 사용한다.
앞서 선언된 onCreate, onRemove, onToggle 함수를 다시 확인해보자.
const onCreate = () => {
const user = {
id: nextId.current,
username,
email,
};
setUsers(users.concat(user));
setInputs({
username: "",
email: "",
});
nextId.current += 1;
};
const onRemove = (id) => {
setUsers(users.filter((user) => user.id !== id));
};
const onToggle = (id) => {
setUsers(
users.map((user) =>
user.id === id ? { ...user, active: !user.active } : user
)
);
};
이 함수들은 컴포넌트가 리렌더링 될 때마다 새로 만들어진다. 함수를 선언하는 것 자체는 리소스를 많이 차지하는 작업이 아니기에 함수 재선언이 큰 부하는 아니지만, 한번 만든 함수를 필요할때만 만들고 재사용하는 것은 여전히 중요한 작업이다.
그 이유는, 이후에 컴포넌트에서 props
가 변경되지 않았다면 Virtual DOM이 새로 렌더링하는 것 조차 하지 않고 컴포넌트의 결과물을 재사용하는 최적화 작업을 할 예정인데, 이 작업을 수행하기 위해 함수의 재사용이 필수적이다.
useCallback
의 사용 방법은 다음과 같다.
import React, { useRef, useState, useMemo, useCallback } from "react";
// ...
const onCreate = useCallback(() => {
const user = {
id: nextId.current,
username,
email,
};
setUsers(users.concat(user));
setInputs({
username: "",
email: "",
});
nextId.current += 1;
}, [users, username, email]);
const onRemove = useCallback(
(id) => {
// user.id가 파라미터로 일치하지 않는 원소만 추출해서 새로운 배열을 만듬
// = user.id가 id인 겂을 제거함
setUsers(users.filter((user) => user.id !== id));
},
[users]
);
const onToggle = useCallback(
(id) => {
setUsers(
users.map((user) =>
/**id가 동일할 경우 user의 기존 속성은 유지하고 active만 변경 */
user.id === id ? { ...user, active: !user.active } : user
)
);
},
[users]
);
// ...
useCallback
props
가 있는 경우 꼭 deps
배열 안에 포함시켜야 한다.props
로 받아온 함수가 있다면 이 또한 deps
에 넣어주어야 한다.useMemo
를 기반으로 만들어졌다.const onToggle = useMemo(
() => () => {
/* ... */
},
[users]
);
useCallback만으로 바로 이뤄낼 수 있는 시각적 최적화는 없다. 다음 파트에서 컴포넌트 렌더링 최적화 작업을 진행해야만 성능이 최적화된다.
컴포넌트의 리랜더링 여부를 확인하기 위해 크롬 확장 프로그램 React DevTools를 이용할 수 있다.
현재는 input 값이 변경될때마다 UserList 컴포넌트가 리렌더링 된다는 문제점이 있다.
이전까지의 코드에서 주요 상태 업데이트 로직은 App 컴포넌트 내부에서 이루어졌다. 상태를 업데이트 할 때에는 useState
를 사용해 새로운 상태를 설정해주었다. 그러나 useState
외에도 상태를 관리할 수 있는 다른 방법이 있는데, 바로 useReducer
를 사용하는 것이다.
이 Hook함수를 사용하면 컴포넌트의 상태 업데이트 로직을 컴포넌트에서 분리시킬 수 있다. 상태 업데이트 로직을 컴포넌트 밖에 작성할 수도 있고, 심지어 다른 파일에 작성 후 불러와서 사용할 수도 있다.
action
: 업데이트를 위한 정보를 보유.type
값을 지닌 객체 형태로 사용 (반드시 따라야 할 규칙은 따로 없다.)function reducer(state, action) {
// 새로운 상태를 만드는 로직
// const nextState = ...
return nextState;
}
action
의 예시action
객체의 형태는 자유이다. type
값을 대문자와 _로 구성하는 관습이 존재하나, 꼭 따라야 하는 것은 아니다.// 카운터에 1을 더하는 액션
{
type: 'INCREMENT'
}
// 카운터에 1을 빼는 액션
{
type: 'DECREMENT'
}
// input 값을 바꾸는 액션
{
type: 'CHANGE_INPUT',
key: 'email',
value: 'tester@react.com'
}
// 새 할 일을 등록하는 액션
{
type: 'ADD_TODO',
todo: {
id: 1,
text: 'useReducer 배우기',
done: false,
}
}
const [state, dispatch] = useReducer(reducer, initialState);
state
: 앞으로 컴포넌트에서 사용할 수 있는 상태를 가르킨다.dispatch
: 액션을 발생시키는 함수.dispatch{{ type: 'INCREMENT' }}
useRedcuer
import React, { useState } from 'react';
function Counter() {
const [number, setNumber] = useState(0);
const onIncrease = () => {
setNumber(prevNumber => prevNumber + 1);
};
const onDecrease = () => {
setNumber(prevNumber => prevNumber - 1);
};
return (
<div>
<h1>{number}</h1>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
);
}
export default Counter;
import React, { useReducer } from "react";
function reducer(state, action) {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
default:
return state;
}
}
function Counter() {
const [number, dispatch] = useReducer(reducer, 0);
const onIncrease = () => {
dispatch({ type: "INCREMENT" });
};
const onDecrease = () => {
dispatch({ type: "DECREMENT" });
};
return (
<div>
<h1>{number}</h1>
<button onClick={onIncrease}>+1</button>
<button onClick={onDecrease}>-1</button>
</div>
);
}
export default Counter;
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import reportWebVitals from "./reportWebVitals";
import Counter from "./Counter";
// const root = ReactDOM.createRoot(document.getElementById('root'));
// root.render(
// <React.StrictMode>
// <App />
// </React.StrictMode>
// );
ReactDOM.render(<Counter />, document.getElementById("root"));
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
index.js
에서 App대신 Counter 컴포넌트를 렌더링 해 정상 작동 여부를 확인할 수 있다.App 컴포넌트에 있던 상태 업데이트 로직들을 useState
가 아닌 useReducer
를 사용하여 구현해보자.
import React, {
useRef,
useState,
useMemo,
useCallback,
useReducer,
} from "react";
import UserList from "./UserList";
import CreateUser from "./CreateUser";
function countActiveUsers(users) {
console.log("활성 사용자 수를 세는 중...");
return users.filter((user) => user.active).length;
}
const initialState = {
inputs: {
username: "",
email: "",
},
users: [
{
id: 1,
username: "velopert",
email: "public.velopert@gmail.com",
active: true,
},
{
id: 2,
username: "tester",
email: "tester@example.com",
active: false,
},
{
id: 3,
username: "liz",
email: "liz@example.com",
active: false,
},
],
};
function reducer(state, action) {
switch (action.type) {
case "CHANGE_INPUT":
return {
...state,
inputs: {
...state.inputs,
[action.name]: action.value,
},
};
case "CREATE_USER":
return {
inputs: initialState.inputs,
users: state.users.concat(action.user),
};
case "TOGGLE_USER":
return {
...state,
users: state.users.map((user) =>
user.id === action.id ? { ...user, active: !user.active } : user
),
};
case "REMOVE_USER":
return {
...state,
users: state.users.filter((user) => user.id !== action.id),
};
default:
return state;
}
}
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const nextId = useRef(4);
const { users } = state;
const { username, email } = state.inputs;
const onChange = useCallback((e) => {
const { name, value } = e.target;
dispatch({
type: "CHANGE_INPUT",
name,
value,
});
}, []);
const onCreate = useCallback(() => {
dispatch({
type: "CREATE_USER",
user: {
id: nextId.current,
username,
email,
},
});
nextId.current += 1;
}, [username, email]);
const onToggle = useCallback((id) => {
dispatch({
type: "TOGGLE_USER",
id,
});
}, []);
const onRemove = useCallback((id) => {
dispatch({
type: "REMOVE_USER",
id,
});
}, []);
const count = useMemo(() => countActiveUsers(users), [users]);
return (
<>
<CreateUser
username={username}
email={email}
onCreate={onCreate}
onChange={onChange}
/>
<UserList users={users} onToggle={onToggle} onRemove={onRemove} />
<div>활성 사용자 수 : {count}</div>
</>
);
}
export default App;
어느 것을 사용하는것이 좋은지에 대해 정해진 답은 없다.
컴포넌트에서 관리하는 값이 딱 하나고, 그 값이 단순한 숫자, 문자열 또는 boolean 값이라면 useState
로 관리하는 것이 편할 것이다.
const [value, setValue] = useState(true);
만약 컴포넌트에서 관리하는 값이 여러개가 되어 상태의 구조가 복잡해진다면 useReducer
로 관리하는 것이 편할 수 있다. 저자는 다음과 같이 setter를 한 함수에서 여러번 사용해야 하는 일이 발생한다면 useReducer의 사용을 고민한다고 한다.
setUsers(users => users.concat(user));
setInputs({
username: '',
email: ''
});
useState
를 사용하면서 여러 상태를 동시에 업데이트해야 하는 경우, 각각의 상태에 대해 setState를 호출해야 하는데, 이럴 경우 코드가 복잡해지고, 상태 업데이트 로직이 여러 곳에 퍼져있게 된다. 반면,useReducer
는 액션에 따라 상태를 어떻게 업데이트할지를 명시해두는 방식으로, 복잡한 상태 업데이트 로직을 한 곳에 모아 관리할 수 있다. 따라서 상태 업데이트 로직이 복잡한 경우나 여러 상태를 동시에 업데이트 해야 하는 경우useReducer
를 사용해 코드를 더욱 간결하고 관리하기 쉽게 만들 수 있다.