0. 들어가기
이전 게시글에서 useMemo, useCallback에 대해 공부를 해봤다.
이번 챕터는 useState에 대한 내용이다.
1. useState
useMemo는 불변 상태(변하지 않는 상태)를 캐시하지만, useState는 가변 상태를 캐시한다.
const [값, 값을_변경하는_세터_함수] = useState(초깃값)
useState 훅이 반환하는 세터 함수는 리액트가 컴포넌트 내부의 상태 변화를 쉽게 감지하게 한다.
즉 리액트는 세터 함수가 호출되면 컴포넌트 상태에 변화가 있다고 보고 즉시 해당 컴포넌트를 리렌더링 한다.
그러나 상태에는 타입이 존재한다.
상태는 number, boolean, string 같은 원시 타입일 수도 있고 객체나 배열, 튜플 타입일 수도 있다.
useState의 선언문을 확인하면 아래와 같다.
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
S는 제네릭 타입 변수로 상태값의 타입을 의미한다.
초기값은 S 타입의 값이거나 S 타입을 반환하는 함수일 수 있다.
[S, Dispatch<SetStateAction<S>>]는 useState가 2개의 요소가 있는 배열을 반환함을 의미한다.
첫 번째 요소는 상태값 S로 현재 저장된 상태값을 나타낸다.
두 번째 요소는 상태 갱신 함수로 새로운 상태값을 설정하는데 사용된다.
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);
Dispatch는 전달된 값을 받아 상태를 업데이트 하는 함수의 타입이다.
SetStateAction<S>는 상태를 갱신하는 값이나 함수일 수 있다.
// 상태를 새로운 값으로 직접 설정
const [count, setCount] = useState(0);
setCount(10); // count는 10으로 설정됨
// 이전 상태를 기반으로 새로운 상태를 계산
const [count, setCount] = useState(0);
setCount(prevCount => prevCount + 1); // count는 이전 값에 1을 더한 값으로 갱신됨
위와 같이 상태를 직접 값으로 정할 수도 있고, 이전 상태를 기반으로 새로운 상태를 계산하는 함수를 전달할 수도 있다.
2. number 타입일 때 useState 사용하기
초기값이 0이고, + 버튼과 - 버튼에 따라 수가 증가하고 감소하는 애플리케이션을 예시로 들어보자.
const [count, setCount] = useState(0)
1만큼 증가시키는 + 버튼은 위에서 설명했다시피 2가지 방법으로 구현할 수 있다.
// 세터 함수에 새 값 추가
const increment = () => setCount(count + 1)
// 이전 상태를 갱신
const increment = () => setCount(count => count + 1)
// 버튼 UI
<button onClick={increment}>+</button>
이 때 increment 콜백 함수를 useCallback으로 캐시하면 count에 의존성 문제가 발생한다.
count의 초깃값이 0일 때, increment가 호출되면 count는 1이 되지만 useCallback 내부에서는 0으로 남아 있다.
그래서 count가 바뀔 때마다 캐시하도록 의존성 배열에 count를 넣어주면 문제는 해결된다.
const increment = useCallback(() => {
setCount(count + 1)
}, []) // 의존성 목록에 count를 넣어야 함
하지만 이전 상태를 갱신한 방법으로 useCallback을 사용하면 의존성 문제가 발생하지 않는다.
const increment = useCallback(() => {
setCount(count => count + 1)
}, [])
리액트는 세터 함수의 입력 변수가 함수일 때는 현재 유지되는 값을 매개변수로 해서 세터 함수를 호출한다.
세터 함수가 반환한 값을 새로운 count로 설정하기 때문에 count에서 의존성 문제가 발생하지 않는 것이다.
즉 항상 최신의 count 값을 인자로 받아, 그 값을 기반으로 업데이트 하기 때문에 그렇다.
3. useState와 고차 함수로 라디오 버튼 상태 구현
여러 직업이 라디오 버튼으로 구현되어 있고, 선택한 직업을 타이틀에 나타내보려고 한다.
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Subtitle, Title } from '../components';
import * as D from '../data';
export default function RadioInputTest() {
const jobTitles = useMemo(() => D.makeArray(4).map(D.randomJobTitle), []);
const [selectedJobTitle, setSelectedJobTitle] = useState(jobTitles[0]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSelectedJobTitle(e.target.value);
}, []);
const radioInput = useMemo(
() =>
jobTitles.map((value, index) => (
<label key={index} className="flex justify-start cursor-pointer label">
<input
type="radio"
name="jobs"
className="mr-4 radio radio-primary"
checked={value === selectedJobTitle}
onChange={onChange}
value={value}
/>
<span className="label-text">{value}</span>
</label>
)),
[jobTitles, onChange, selectedJobTitle]
);
return (
<section className="mt-4">
<Title>RadioInputTest</Title>
<div className="flex flex-col justify-center mt-4">
<Subtitle>What is your job?</Subtitle>
<Subtitle className="mt-4">seleced job: {selectedJobTitle}</Subtitle>
<div className="flex justify-center p-4 mt-4">
<div className="flex flex-col mt-4">{radioInput}</div>
</div>
</div>
</section>
);
}
위와 같이 구현할 수 있는데, 고차 함수를 구현해서 좀더 간결하게 만들어볼 수 있다.
콜백 함수인 onChange에 number 타입인 index 매개변수를 전달해 () => setSelectedIndex를 반환하게 만들었다.
import { useCallback, useMemo, useState } from 'react';
import { Subtitle, Title } from '../components';
import * as D from '../data';
export default function HigherOrderRadioInputTest() {
const jobTitles = useMemo(() => D.makeArray(4).map(D.randomJobTitle), []);
const [selectedIndex, setSelectedIndex] = useState(0);
const onChange = useCallback(
(index: number) => () => {
setSelectedIndex(index);
},
[]
);
const radioInput = useMemo(
() =>
jobTitles.map((value, index) => (
<label key={index} className="flex justify-start cursor-pointer label">
<input
type="radio"
name="jobs"
className="mr-4 radio radio-primary"
checked={index === selectedIndex}
onChange={onChange(index)}
/>
<span className="label-text">{value}</span>
</label>
)),
[jobTitles, onChange, selectedIndex]
);
return (
<section className="mt-4">
<Title>RadioInputTest</Title>
<div className="flex flex-col justify-center mt-4">
<Subtitle>What is your job?</Subtitle>
<Subtitle className="mt-4">selected job: {jobTitles[selectedIndex]}</Subtitle>
<div className="flex justify-center p-4 mt-4">
<div className="flex flex-col mt-4">{radioInput}</div>
</div>
</div>
</section>
);
}
4. FormData와 객체 타입일 때 useState 사용하기
이름과 이메일을 작성해 폼을 제출했을 때 alert에 JSON 포맷으로 제출한 내용을 보려고 한다.
이를 위해 알아야 할 내용은 4가지다.
1. form 구현
서버에서 HTML을 생성해 웹 브라우저로 전송하는 전통 방식의 웹 개발에서 <form> 요소는 method 속성에 데이터를 전송할 HTTP 메소드를 설정하고, action 속성에서는 폼 데이터를 전송한 뒤 전환할 화면의 URL을 설정한다.
만약 method가 POST라면 폼 데이터를 암호화 하는 3가지 방식 중 하나를 encType에 설정한다.
1. application/x-www-form-urlencoded (기본값)
2. multipart/form-data
3. text/plain
하지만 리액트 같은 SPA에서는 백엔드 웹 서버가 API 방식으로 동작하기에 위와 같은 속성을 설정할 필요가 없다.
전통적인 방식처럼 <form> 요소로 데이터를 직접 서버에 전송하지 않고, fetch나 axios 같은 AJAX로 비동기 처리 되기 때문이다.
다만 관습적으로 유저의 입력을 받는 부분은 <form> 요소로 구현한다.
(간단히 말해 전통적인 MPA와 SPA의 방식 차이로 인해 폼 데이터를 다루는 방식이 달라졌다.)
const onSubmit = (e: EventChange<HTMLFormElement>) => {
e.preventDefault()
}
<form onSubmit={onSubmit}>
<input type="submit" value="버튼_텍스트" />
</form>
위와 같이 구현하되 웹 브라우저는 onSubmit 이벤트가 발생하면 <form>이 있는 페이지를 리렌더링 하기 때문에,
반드시 e.preventDefault()로 페이지가 다시 렌더링되지 않도록 해야 한다.
2. FormData 클래스
FormData는 JS가 기본으로 제공하는 클래스로, 유저가 입력한 데이터들을 웹 서버에 전송할 목적으로 쓰인다.
여러 메소드들이 있지만 보통은 append()면 충분하다. (키, 값 형태의 데이터를 추가하는 메서드)
const formData = new FormData();
formData.append('name', 'Jack')
formData.append('email', '111@naver.com')
만약 formData의 내용을 JSON 포맷으로 바꾸고 싶다면 Object.fromEntries()를 호출하면 된다.
const json = Object.fromEntries(formData)
이제 구현 코드를 확인하면서 이해해보자.
import { ChangeEvent, useCallback, useState } from 'react';
import { Title } from '../components';
import { Input } from '../theme/daisyui';
export default function BasicForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const onSubmit = useCallback(
(e: ChangeEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData();
formData.append('name', name);
formData.append('email', email);
alert(JSON.stringify(Object.fromEntries(formData), null, 2));
},
[email, name]
);
const onChangeName = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}, []);
const onChangeEmail = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
}, []);
return (
<section className="mt-4">
<Title>BasicForm</Title>
<div className="flex justify-center mt-4">
<form onSubmit={onSubmit}>
<div className="form-control">
<label className="label" htmlFor="name">
<span className="label-text">Username</span>
</label>
<Input
value={name}
onChange={onChangeName}
id="name"
type="text"
placeholder="enter your name"
className="input-primary"
/>
</div>
<div className="form-control">
<label className="label" htmlFor="email">
<span className="label-text">email</span>
</label>
<Input
value={email}
onChange={onChangeEmail}
id="email"
type="email"
placeholder="enter your email"
className="input-primary"
/>
</div>
<div className="flex justify-center mt-4">
<input
type="submit"
value="SUBMIT"
className="w-1/2 btn btn-sm btn-primary"
/>
<input
type="button"
defaultValue="CANCEL"
className="w-1/2 ml-4 btn btn-sm"
/>
</div>
</form>
</div>
</section>
);
}
코드가 좀 길지만 UI 구현이 대부분이다.
useState로 name, email의 각각 초깃값을 ''으로 받아주고, 변경될 때를 useCallback으로 감싸줬다.
제출할 때 preventDefault를 해주고, formData에 name, email을 append 해두었다.
이 때 형태를 JSON 문자열로 만들기 위해 JSON.stringify와 Object.fromEntries를 사용했다.
replacer는 직렬화 할 객체의 특정 속성만 선택하거나 값을 변환할 수 있게 하는 함수 또는 배열이다. 지금은 null로 적용되지 않았다.
뒤의 2는 space로 2칸 들여쓰기가 적용되었다.
그래서 위에 2칸 들여쓴 JSON 문자열이 alert에 뜬 것을 알 수 있다.
위 코드는 name, email을 각각 문자열로 받아주고 있는데, 요소들이 많아지면 동일한 포맷의 상태 코드를 작성해줘야 할 것이다.
이것을 좀 더 간결하게 객체의 속성 상태로 구현해보자.
// 기존 코드
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// 객체 타입으로 관리
type FormType = {
name: string;
email: string;
};
const [form, setForm] = useState<FormType>({ name: '', email: '' });
이처럼 객체로 상태를 만든 후 onChangeName, onChangeEmail 같은 콜백 함수를 작성해야 하는데,
이 때 깊은 복사와 얕은 복사, 전개 연산자 구문에 대한 이해가 필요해진다.
3. 깊은 복사와 얕은 복사
복사 방식은 값의 타입에 따라 달라진다.
number, boolean처럼 메모리 크기를 컴파일 타임 때 알 수 있는 타입은 항상 깊은 복사가 일어난다.
string 타입 문자열은 TS에서 항상 읽기 전용이므로 메모리 크기를 컴파일 때 알 수 있어 문자열도 깊은 복사가 일어난다. (원시 타입)
하지만 객체, 배열처럼 메모리 크기를 런타임 때 알 수 있는 타입(참조 타입)은 얕은 복사가 일어난다.
(메모리 주소(참조)만 복사하기 때문에 같은 메모리 주소를 가리켜 한쪽을 수정하면 원본과 복사본이 동시에 변경된다.)
const onChangeName = useCallback((e: EventChange<HTMLInputElement>) => {
const newForm = form // 얕은 복사
// const newForm = Object.assign({}, form) // 깊은 복사
newForm.name = e.target.value
setForm(newForm)
}, [form])
리액트는 form 상태의 변화를 form === newForm으로 비교한다.
객체와 같은 참조 타입은 항상 얕은 복사가 일어나므로 이 비굣값은 항상 true가 된다.
따라서 깊은 복사를 일으키면 되는데, 이런 형태보다 전개 연산자 구문을 통해 좀더 간단하게 구현할 수 있다.
4. 전개 연산자
const onChangeName = useCallback((e: EventChange<HTMLInputElement>) => {
const newForm = {...form}
newForm.name = e.target.value
setForm(newForm)
}, [form])
Object.assign을 전개 연산자로 좀더 간결하게 구현했다.
복사 뿐만 아니라 속성값 변경도 같이 하는 코드로 수정해보자.
const [form, setForm] = useState<FormType>({ name: '', email: '' });
const onSubmit = useCallback(
(e: ChangeEvent<HTMLFormElement>) => {
e.preventDefault();
alert(JSON.stringify(form, null, 2));
},
[form]
);
const onChangeName = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm(state => ({ ...state, name: e.target.value }));
}, []);
const onChangeEmail = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm(state => ({ ...state, email: e.target.value }));
}, []);
위와 같이 ...state로 기존의 속성을 복사하고, name이나 email처럼 특정 부분만 변경했다.
기존 UI에서 value도 name, email이 아니라 form.name, form.email로 바꿔주면 된다.
**참고
리액트로 웹앱 만들기 with 타입스크립트
'⚛️ React' 카테고리의 다른 글
[React] useMemo와 useCallback (0) | 2024.09.23 |
---|---|
[React] import React from 'react'와 이별하기 (feat. React 17, rafce) (0) | 2024.04.21 |
[React] React의 렌더링 방식과 웹 브라우저의 동작 (0) | 2024.03.28 |
[React] Vite의 환경 변수 (cf. CRA) (0) | 2024.02.16 |
[React] useState (0) | 2024.02.03 |