불변성 유지의 중요성과 immer를 사용해 간단하게 불변성을 유지하는 방법을 알아보자
상태관리에 있어서 불변성은 중요한 개념이다. 참조타입 변수들(ex. 배열,객체)에 push등을 사용해 통째로 상태를 근본적으로 변경해버리면 이전 상태를 참조할 수 없게 된다. 즉, 상태가 바뀌어도 바뀐 걸 최적화 과정에서 인지할 수 없다. 그래서 보통 spread syntax나 concat등을 사용해 간접적으로 값만 가져오는 방법을 사용한다. 예시를 좀 보자.
const array = [1,2,3,4,5];
const nextArrayBad = array;
nextArrayBad[0] = 100;
console.log(array===nextArrayBad);
위 코드를 콘솔에 찍어보면 true가 나온다. 배열이나 객체는 reference type이기 때문에 이 다른 변수에 할당해주면 값의 복사가 아닌 그냥 한 객체에 두 변수가 연동돼버린다.
const nextArrayGood = [...array];
nextArrayGood[0] = 100;
console.log(nextArrayGood===array);
이번엔 전개구문을 사용해 값을 복사해줬다. 값만 가져왔기에 둘은 아예 다른 배열이다. 그렇기에 콘솔엔 false가 찍힌다.
const obj = {
foo : 'bar',
value : 1
};
const newObj = {
...obj,
value: 2
}
console.log(obj===newObj) // false
객체도 똑같다.
const todos = [{ id:1, checked:true}, {id:2, checked:true}];
const nextTodos = [...todos];
nextTodos[0].checked = false;
console.log(todos[0] === nextTodos[0]);
// true
코드를 읽어보자. 분명 전개구문으로 배열을 복사해 왔는데 왜 콘솔은 여전히 두 배열이 같다고 말하는 걸까?
전개 연산자를 사용해 객체나 배열의 값을 복사할 때 내부의 값은 복사되지 않는다. 요컨대 nextTodos의 배열 내부값인 두 객체는 여전히 todos와 연결돼 있는 것이다. 그렇기에 nextTodos의 값을 수정한 것 같지만 실제로는 todos의 내부값을 수정한 것이다. 내부값도 확실히 복사하고 싶다면 아래처럼 작성해야 한다.
nextTodos[0] = {
...nextTodos[0],
chekced:false
};
console.log(todos[0]===nextTodos[0]) // false
객체 안에 있는 객체를 수정하고 싶다면 아래처럼 해준다.
const nextComplexObject = {
...complexObject,
objectInside: {
...complexObject.objectInside,
enable : false
}
);
console.log(complexObject === nextComplexObject);
// false
console.log(complexObject.objectInside ===
nextComplexObject.objectInside);
// false
다소 복잡하다.. 하지만 매번 이렇게 할 필요는 없다. immer를 비롯해 불변성 유지를 도와주는 라이브러리들이 있기 때문이다.
포스팅 하는 걸 구경하던 독일어 전공자 동기가 immer는 독어로 ‘항상’ 이란 뜻이라고 설명해줬다..
import {useRef,useCallback,useState} from 'react';
const App = () =>{
const nextId = useRef(1);
const [form,setForm] = useState({name:'',username:''});
const [data,setData] = useState({
array : [],
uselessValue: null
});
const onChange = useCallback(
e =>{
const {name,value} = e.target;
setForm({
...form,
[name]:[value]
});
},
[form]
);
const onSubmit = useCallback(
e=>{
e.preventDefault();
const info={
id:nextId.current,
name:form.name,
username:form.username
};
setData({
...data,
array:data.array.concat(info)
});
setForm({
name:'',
username:''
});
nextId.current+=1;
},
[data, 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>
);
};
export default App;
아이디와 이름을 입력하면 아래에 보여주는 페이지다. 코드 중간중간에 부변성 유지르 위한 노력들이 보이는데, 이 작업을 immer로 해보자.
바뀐 부분만 확인해보겠다.
const onChange = useCallback(
e =>{
const {name,value} = e.target;
setForm(
produce(form,draft=>{
draft[name] = value;
})
);
},
[form]
)
const onSubmit = useCallback(
e=>{
e.preventDefault();
const info={
id:nextId.current,
name:form.name,
username:form.username
};
setData(
produce(data,draft=>{
draft.array.push(info);
})
);
setForm({
name:'',
username:''
});
nextId.current+=1;
},
[data, form.name ,form.username]
);
const onRemove = useCallback(
id=>{
setData(
produce(data,draft=>{
draft.array.splice(draft.array.findIndex(info=>info.id===id),1);
})
);
},
[data]
);
상태 설정 함수들에 전부 immer의 produce함수를 사용해줬다. produce함수의 첫 번째 파라미터는 수정하고 싶은 값, 두 번째는 상태를 업데이트 할지 정의하는 함수다.
const onChange = useCallback(
e =>{
const {name,value} = e.target;
setForm(
produce(form,draft=>{
draft[name] = value;
})
);
},
[form]
)
setForm내부를 잘 보자. produce함수를 통해 form을 수정하고 있으며, 새로 반환할 상태(객체)의 내부값을 선언해주고 있다. 덮어씌워주는 방식을 사용하지 않아도 produce함수가 알아서 불변성을 유지한 채로 업데이트 된 새로운 상태를 반환해준다. 그 반환값이 setForm에 들어가 form의 상태를 업데이트한다.
immer의 핵심은 불변성은 신경 쓰지 않으며 코딩해도 불변성 관리는 제대로 이루어진다는 것이다.
다만 위 코드에서 onRemove함수는 splice를 사용해 특정값을 잘라내고 있는데, 사실 filter먹인 배열을 한번 덮어씌워주는 편이 더 간단하다.
항상 immer를 사용할 필요는 없고 필요에 따라, 복잡해지면 사용하면 된다.
컴포넌트 최적화에서 배운 useState의 함수형 업데이트를 immer와 함께 사용할 수 있다. 예시를 먼저 보자.
const update = produce(draft=>{
draft.value=2;
});
const originalState = {
value:1,
foo:'bar',
};
const nextState = update(originalState);
console.log(nextState);
// {value:2, foo:'bar'}
수정하고 싶은 state를 파라미터로 넣는 대신 바로 draft함수를 넘겨줬다. 이런 경우 produce는 함수를 반환한다. 반환한 함수는 update에 할당돼 nextState의 상태를 수정하는 데 사용됐다.
상태 업데이트 함수 내부에 명시적인 상태 대신 업데이트가 되는 과정이 담긴 함수를 넣어줬던 것처럼, produce를 그대로 넣어줘도 된다. 다 보기엔 너무 많으니 함수 하나만 바꿔보자.
const onChange = useCallback(
e =>{
const {name,value} = e.target;
setForm(
produce(draft=>{
draft[name] = value;
})
);
},
[form]
)
그냥 state값만 지워주면 된다. 이렇게 쓰는 편이 더 보기 좋다.
불변성 유지, 얕은 복사.. 굵직한 개념들이 나왔다.
언젠가 더 깊은 이해가 필요해지면 구글링해보자