불변성이란 React를 관통하는 핵심 키워드입니다.
불변성이란, 어떤 값을 직접적으로 변경하지 않고 새로운 값을 만들어 내는 것을 말합니다.
원시 타입
원시타입은 Number, Boolean, null, undefined, String과 같이 5가지가 존재합니다.
참조 타입
Object, Array, function이 있습니다.
먼저, 원시 타입 데이터는 변수에 할당될 때 메모리 상에 고정된 크기로 저장되고 해당 변수가 원시 데이터의 값을 보관합니다. 원시 타입 자료형은 변수 선언, 초기화, 할당시 값이 저장된 메모리 영역에 직접 접근합니다.
그러기 때문에, 변수에 새 값이 할당될 때마다, 변수에 할당된 메모리 블럭에 저장된 값을 바로 변경합니다. 이를 Access By Value라고 합니다.
참조 타입 데이터는 크기가 정해져있지 않고 변수에 할당될 때 값이 직접 해당 변수에 저장될 수 없고, 변수에는 데이터에 대한 참조만 저장됩니다. 즉, 변수에는 값이 저장된 힙 메모리의 주소값을 저장합니다.
정리하면, 참조타입은 변수의 값이 저장된 메모리 블럭의 주소를 가지고 있고 JS 엔진이 변수가 가지고 있는 메모리 주소를 이용해, 변수의 값에 접근합니다. → Access By Reference
불변성이란 어떤 값을 직접적으로 변경하지 않고 새로운 값을 만들어내는 것입니다.
위는 불변성에 대한 정의로, 우리는 불변성을 지키기 위해서 필요한 값을 변형해 사용하고 싶다면 어떤 값을 사본을 만들어 사용해야 합니다.
결론적으로, 원시 타입의 경우 직접 변경해도 상관 없지만 참조 타입의 경우 불변성에 대한 고려가 필요합니다.
const user = { name: 'Yeum', age: 25 };
const copyUser = user;
// user와 copyUser 변수에는 같은 참조값 즉, 같은 힙 영역의 메모리를 가지고 있습니다.
물론, 위와 같이 사용할 수 있지만 둘은 같은 힙 영역의 메모리를 가지고 있기 때문에 어느 하나가 값을 변경하면 어디에서 값을 바꾸었는지를 알 수 없습니다. → 에러 유발 가능성
그러기때문에 참조 타입의 경우에는 같은 메모리 주소를 공유하기 보다, 값 자체를 복사해서 전달해주는 것이 훨씬 좋습니다.
const user = { name: 'Yeum', age: 25 };
const copyUser = { ...user };
안타깝게도, 아직 문제가 존재합니다.
우리가 위에서 사용했던 스프레드 연산자는 얕은 복사 ( Shallow Copy ) 즉, 한 단계까지만 복사하게 됩니다. 그러기 때문에 객체 안에 또 다른 참조 타입이 있다면 본래의 힙 메모리가 다시 또 복사되게 되는것이죠..
const user = { name: 'Choi', age: 25, friends: ['Park', 'Kim']};
const otherUser = { ...user };
user.name = 'Lee';
user.friends.push('Kang');
console.log(user === othorUser) // false
console.log(user.friends === othorUser.friends) // true
이와 같이 객체의 구조가 복잡해질수록 불변성의 유지가 어려워지게 됩니다.
그래서, Immer와 같은 라이브러리를 사용하게 되는 이유입니다.
yarn create react-app immer-tutorial
cd immer-tutorial
yarn add immer
import { useCallback, useRef, useState } from 'react';
export default function App() {
const nextId = useRef(1);
const [form, setForm] = useState({ name: '', userName: '' });
const [data, setData] = useState({ array: [], uselessValue: null });
//input 수정을 위한 함수
const onChange = useCallback(
e => {
const { name, value } = e.target;
setForm({
...form,
[name]: [value],
});
},
[form]
);
// form 등록을 위한 함수
const onSubmit = useCallback(
e => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
// array 에 새 항목 등록
setData({
...data,
array: data.array.concat(info),
});
// form 초기화
setForm({
name: '',
username: '',
});
nextId.current += 1;
},
[form.name, form.username]
);
const onRemove = useCallback(
id => {
setData({
...data,
array: data.array.filter(info => info.id !== id),
});
},
[data]
);
return (
<div>
<form onSubmit={onSubmit}>
<input name="username" placeholder="아이디" value={form.username} onChange={onChange} />
<input name="name" placeholder="이름" value={form.name} onChange={onChange} />
<button type="submit">등록</button>
</form>
<div>
<ul>
{data.array.map(info => (
<li key={info.id} onClick={() => onRemove(info.id)}>
{info.username} ({info.name})
</li>
))}
</ul>
</div>
</div>
);
}
이와 같이 전개 연산자와 배열 내장 함수를 사용해 불변성을 유지하면 객체가 깊어질 수 록 이를 유지하기가 매우 번거로워질 수 있습니다.
immer를 사용하면 불변성을 유지하는 작업을 매우 간단하게 처리할 수 있습니다.
produce
함수의 정의는 다음과 같습니다.
produce(currentState, recipe: (draftState) => void): nextState
produce
함수는 두 가지 파라미터를 받습니다.
두 번째 콜백 함수로 전달되는 함수 내부에서 원하는 값을 변경하면 produce
함수가 불변성 유지를 대신해 주면서 새로운 상태를 생성해 줍니다.
import produce from "immer"
const baseState = [
{
title: "Learn TypeScript",
done: true
},
{
title: "Try Immer",
done: false
}
]
const nextState = produce(baseState, draftState => {
draftState.push({title: "Tweet about it"})
draftState[1].done = true
})
⇒ 두 번째 콜백 함수에서 내가 원하는 값을 변경하면 편리하게 불변성을 유지할 수 있다.
두 번째 콜백함수에는 일반적으로 draft
를 전달해주는데, 이 단어는 "초안" 이라는 뜻으로 복사된 객체를 우리가 수정해 최종본으로 만들어 반환하는 방법이라고 생각하면 될 것 같습니다.
//input 수정을 위한 함수
const onChange = useCallback(
e => {
const { name, value } = e.target;
setForm(
produce(form, draft => {
draft[name] = value;
})
// {
// ...form,
// [name]: [value],
// }
);
},
[form]
);
// form 등록을 위한 함수
const onSubmit = useCallback(
e => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
// array 에 새 항목 등록
setData(
produce(data, draft => {
draft.array(info);
})
// {
// ...data,
// array: data.array.concat(info),
// }
);
// form 초기화
...
[form.name, form.username]
);
const onRemove = useCallback(
id => {
setData(
produce(data, draft => {
draft.array.splice(
draft.array.findIndex(info => info.id === id),
1
);
})
// {
// ...data,
// array: data.array.filter(info => info.id !== id),
// }
);
},
[data]
);
이와 같이, 매우 직관적으로 불변성을 유지할 수 있습니다.
immer에서 제공하는 produce
함수를 호출시, 첫 번째 파라미터에 콜백 함수로 선언하고 원본 객체를 전달하면 업데이트를 한 객체 복사본이 전달됩니다.
const update = produce(draft => {
draft.value = 2;
});
const originalState = {
value: 1,
foo: 'bar'
};
const updateState = update(originalState);
console.log(updateState); // { value: 2, foo: 'bar' }
//input 수정을 위한 함수
const onChange = useCallback(
e => {
const { name, value } = e.target;
setForm(
produce(draft => {
draft[name] = value;
})
// produce(form, draft => {
// draft[name] = value;
// })
);
},
[form]
);
// form 등록을 위한 함수
const onSubmit = useCallback(
e => {
e.preventDefault();
const info = {
id: nextId.current,
name: form.name,
username: form.username,
};
// array 에 새 항목 등록
setData(
produce(draft => {
draft.array(info);
})
// produce(data, draft => {
// draft.array(info);
// })
);
...
nextId.current += 1;
},
[form.name, form.username]
);
const onRemove = useCallback(
id => {
setData(
produce(draft => {
draft.array.splice(
draft.array.findIndex(info => info.id === id),
1
);
})
// produce(data, draft => {
// draft.array.splice(
// draft.array.findIndex(info => info.id === id),
// 1
// );
// })
);
},
[data]
);