리액트에서 배열이나 객체를 업데이트 해야 할 때 직접 수정하면 안되고 불변성을 지켜주면서 업데이트를 해줘야한다.
예 )
const object = {
a: 1,
b: 2
};
object.b = 3;
객체가 있다면...
연산자를 사용해서 새로운 객체를 만들어줘야한다.
const object = {
a: 1,
b: 2
};
const nextObject = {
...object,
b: 3
};
배열도 마찬가지로 push, splice 등의 함수를 사용해서 n번째 항목을 직접 수정하면 안되고 concat, filter, map 등의 함수를 사용해야 한다.
const todos = [
{
id: 1,
text: '할 일 #1',
done: true
},
{
id: 2
text: '할 일 #2',
done: false
}
];
const inserted = todos.concat({
id: 3,
text: '할 일 #3',
done: false
});
const filtered = todos.filter(todo => todo.id !== 2);
const toggled = todos.map(
todo => todo.id === 2
? {
...todo,
done: !todo.done,
}
: todo
);
대부분 경우 배열 내장함수나 연산자를 통해서 어렵지 않게 코드를 작성할 수 있지만, 조금만이라도 데이터가 까다라워지면 불변성을 지켜가면서 새로운 데이터를 생성하는 코드를 작성해야한다.
가령 다음과 같은 객체가 있을 때
const state = {
posts: [
{
id: 1,
title: '제목입니다.',
body: '내용입니다.',
comments: [
{
id: 1,
text: '와 정말 잘 읽었습니다.'
}
]
},
{
id: 2,
title: '제목입니다.',
body: '내용입니다.',
comments: [
{
id: 2,
text: '또 다른 댓글 어쩌고 저쩌고'
}
]
}
],
selectedId: 1
};
위에서 posts 배열 안의 id 가 1인 객체를 찾아서, comments 에 새로운 댓글 객체를 추가해줘야 한다고했을 때, 우리는 다음과 같이 업데이트를 해줘야한다.
const nextState = {
...state,
posts: state.posts.map(post =>
post.id === 1
? {
...post,
comments: post.comments.concat({
id: 3,
text: '새로운 댓글'
})
}
: post
)
};
어렵지는 않지만 코드 구조가 좀 복잡해서 코드를 봤을 때 한 눈에 들어오질 않는다.
이 때, immer 이라는 라이브러리를 사용해서 구현할 수 있다.
const nextState = produce(state, draft => {
const post = draft.posts.find(post => post.id === 1);
post.comments.push({
id: 3,
text: '와 정말 쉽다!'
});
});
전보다 훨씬 깔끔하게 잘 읽혀진다.
Immer 를 사용하면 우리가 상태를 업데이트 할 때, 불변성을 신경쓰지 않으면서 업데이트를 해주면 Immer 가 불변성 관리를 대신 해준다.
이번에는 우리각 만든 사용자 관리 프로젝트에 Immer를 적용해서 사용법에 대해 알아보겠다.
우선은 밑에 명령어를 실행하여 Immer 를 설치해준다.
$ yarn add immer
이 라이브러리를 사용할 때 다음과 같이 사용한다.
코드의 상단에 불러와줘야하는데, 보통 produce 라는 이름으로 불러온다.
import produce from 'immer';
produce 함수를 사용할 때, 첫 번째 파라미터로 수정하고 싶은 상태, 두 번째 파라미터에는 어떻게 업데이트하고 싶을지 정의하는 함수를 적어준다.
두번째 파라미터에 넣는 함수에서는 불변성에 대해서 신경쓰지 않고 그냥 업데이트 해주면 다 알아서 해준다.
const state = {
number: 1,
dontChangeMe: 2
};
const nextState = produce(state, draft => {
draft.number += 1;
});
console.log(nextState);
// { number: 2, dontChangeMe: 2 }
콘솔에는...
우리가 알야할 점은 Immer 를 사용해서 간단해지는 업데이트도 있지만, 반대로 길어지는 업데이트도 존재한다.
예를들어 우리가 만들었던 프로젝트의 상태의 경우 users 배열이 객체의 깊은곳에 위치하지 않기 때문에 새 항목을 추가하거나 제거 할 때는 Immer 를 사용하는 것 보다 concat 이나 filter 를 사용하는것이 더 간편하고 편리하다.
사용법을 잘 알기위해 우리가 진행하고 있는 프로젝트에 사용해서 적용해보겠다.
import React, { useReducer, useMemo } from 'react';
import UserList from './UserList';
import CreateUser from './CreateUser';
import produce from 'immer';
function countActiveUsers(users) {
console.log('활성 사용자 수를 세는중...');
return users.filter(user => user.active).length;
}
const initialState = {
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 'CREATE_USER':
return produce(state, draft => {
draft.users.push(action.user);
});
case 'TOGGLE_USER':
return produce(state, draft => {
const user = draft.users.find(user => user.id === action.id);
user.active = !user.active;
});
case 'REMOVE_USER':
return produce(state, draft => {
const index = draft.users.findIndex(user => user.id === action.id);
draft.users.splice(index, 1);
});
default:
return state;
}
}
// UserDispatch 라는 이름으로 내보내줍니다.
export const UserDispatch = React.createContext(null);
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const { users } = state;
const count = useMemo(() => countActiveUsers(users), [users]);
return (
<UserDispatch.Provider value={dispatch}>
<CreateUser />
<UserList users={users} />
<div>활성사용자 수 : {count}</div>
</UserDispatch.Provider>
);
}
export default App;
TOGGLE_USER 액션의 경우에는 Immer 를 사용해서 코드가 깔끔해졌지만, 나머지같은 경우에는 오히려 더 복잡해졌다.
우리는 상황에 따라 사용하면되고 모든 업데이트 로직에 사용할필요가 없다.
우리는 이전에 useState 를 사용할 때, 함수형 업데이트란걸 배웠다.
예 )
const [todo, setTodo] = useState({
text: 'Hello',
done: false
});
const onClick = useCallback(() => {
setTodo(todo => ({
...todo,
done: !todo.done
}));
}, []);
위처럼 setTodo 함수에 업데이트를 해주는 함수를 넣음으로써, 만약 useCallback 을 사용하는 경우 두번째 파라미터인 deps 배열에 todo 를 넣지 않아도 되게 한다.
이 때, Immer 를 사용하면 코드를 더 간단하게 작성할 수 있다.
만약에 produce 함수에다가 두개의 파라미터를 넣는다면, 첫 번째 파라미터에 넣은 상태를 불변성을 유지하면서 새로운 상태를 만들어주지만, 만약에 첫번째 파라미터를 생략하고 바로 업데이트 함수를 넣는다면, 반환 값은 새로운 상태가 아닌 상태를 업데이트 해주는 함수가 된다. 코드를 살펴보겠다.
const todo = {
text: 'Hello',
done: false
};
const updater = produce(draft => {
draft.done = !draft.done;
});
const nextTodo = updater(todo);
console.log(nextTodo);
// { text: 'Hello', done: true }
결국에 produce 함수가 반환하는것이 업데이트 함수이기 때문에, useState 의 업데이트 함수를 사용할 때 우리는 밑에처럼 구현할 수 있다.
const [todo, setTodo] = useState({
text: 'Hello',
done: false
});
const onClick = useCallback(() => {
setTodo(
produce(draft => {
draft.done = !draft.done;
})
);
}, []);
Immer 는 편한 라이브러리는 확실하다. 하지만, 성능면에서는 Immer 를 사용하지 않은 코드가 더 빠르다.
참고 : 벨로퍼트와 함께하는 모던 리액트
느낀점 :