2024. 6. 10. 21:40ㆍ리액트 (React)/리액트 레시피 (React Recipt)
Objective
5. State로 리렌더링하기 글에서 배운 것을 활용해서 이번에는 메시지를 작성해서 보내도록 해보겠습니다.
사용자 예상 시나리오는 다음과 같이 만들었습니다.
- 빈 입력창과 누를 수 없는 보내기 버튼이 있다.
- 입력창에 1개라도 글자가 있으면 보내기 버튼을 누를 수 있다.
- 보내기 버튼을 누르면 3초 동안
- 입력창과 보내기 버튼이 비활성화된다.
- 보내기 버튼에 스피너가 등장한다.
- 3초 이후
- 입력창과 버튼이 최초 모습으로 바뀐다.
- 입력된 글자로 메시지가 오른쪽에 새롭게 등장한다.
Step 1
빈 입력창과 누를 수 없는 보내기 버튼이 있다.
간단하게 만들어봅시다.
import "./Input.scss";
export default function Input() {
return (
<div className="input-box">
<form className="input-form">
<i className="fa-solid fa-chevron-right" style={{ marginRight: "10px" }} />
<input className="input" type="text" />
<button className="send" type="submit" disabled>
<i className="fa-solid fa-paper-plane" />
</button>
</form>
</div>
);
}
Step 2
입력창에 1개라도 글자가 있으면 보내기 버튼을 누를 수 있다.
입력창에 놓여져 있는 글자가 1개라도 있는 지 확인하려면 입력창 상태를 만들어야 합니다.
따라서 먼저 state로 message를 만들고
const [message, setMessage] = useState<string>("");
input 태그의 어트리뷰트로 설정합니다.
- onChange를 통해 사용자가 입력할 때마다 message를 변경하여 리렌더링이 되도록 합니다.
- value에는 message를 넣어주므로써 입력한 글자가 그대로 표현되도록 합니다.
<input
className="input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
따라서 아래와 같이 만들 수 있습니다.
import { useState } from "react";
import "./Input.scss";
export default function Input() {
const [message, setMessage] = useState<string>("");
return (
<div className="input-box">
<form className="input-form">
<i className="fa-solid fa-chevron-right" style={{ marginRight: "10px" }} />
<input
className="input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button className="send" type="submit" disabled={message.length === 0}>
<i className="fa-solid fa-paper-plane" />
</button>
</form>
</div>
);
}
Step 3 - 1
보내기 버튼을 누르면 3초 동안, 입력창과 보내기 버튼이 비활성화된다.
우선 3초 동안 기다릴 수 있는 함수 wait를 만들어봅니다.
const WAIT_SUBMIT_MILLISECOND = 3000;
export default function Input() {
...
function waitSubmit() {
return new Promise((resolve) => setTimeout(resolve, WAIT_SUBMIT_MILLISECOND));
}
...
그리고 현재 기다리는 지 아니면 안 기다리는 지 알 수 있는 state를 하나 더 추가해야 합니다.
const [isWait, setIsWait] = useState<boolean>(false);
기다린다고 하면, 입력창과 보내기 버튼이 비활성화되어야 하므로 disable 어트리뷰트에 각각 설정해줍니다.
<div className="input-box">
<form className="input-form">
<i className="fa-solid fa-chevron-right" style={{ marginRight: "10px" }} />
<input
className="input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isWait}
/>
<button className="send" type="submit" disabled={message.length === 0 || isWait}>
<i className="fa-solid fa-paper-plane" />
</button>
</form>
</div>
마지막으로 보내기 버튼을 눌렀을 때, form 태그에 있는 onSubmit이 호출되므로
3초간 기다릴 수 있도록 여기에 waitSubmit 함수를 추가해봅니다.
<form
className="input-form"
onSubmit={(e) => {
e.preventDefault();
setIsWait(true);
waitSubmit().finally(() => {
setIsWait(false);
});
}}
>
...
</form>
따라서 아래와 같이 만들 수 있습니다.
import { useState } from "react";
import "./Input.scss";
const WAIT_SUBMIT_MILLISECOND = 3000;
export default function Input() {
const [message, setMessage] = useState<string>("");
const [isWait, setIsWait] = useState<boolean>(false);
function waitSubmit() {
return new Promise((resolve) => setTimeout(resolve, WAIT_SUBMIT_MILLISECOND));
}
return (
<div className="input-box">
<form
className="input-form"
onSubmit={(e) => {
e.preventDefault();
setIsWait(true);
waitSubmit().finally(() => {
setIsWait(false);
});
}}
>
<i className="fa-solid fa-chevron-right" style={{ marginRight: "10px" }} />
<input
className="input"
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isWait}
/>
<button className="send" type="submit" disabled={message.length === 0 || isWait}>
<i className="fa-solid fa-paper-plane" />
</button>
</form>
</div>
);
}
Step 3 - 2
보내기 버튼을 누르면 3초 동안, 보내기 버튼에 스피너가 등장한다.
간단하게 삼항 연산자를 통해 만들어봅니다.
<button className="send" type="submit" disabled={message.length === 0 || isWait}>
{isWait ? (
<i className="fa-solid fa-spinner" />
) : (
<i className="fa-solid fa-paper-plane" />
)}
</button>
애니메이션은 아래 코드를 사용하면 됩니다.
i.fa-solid.fa-spinner {
font-size: 1.2em;
animation: spin steps(1, end) 1s infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
12.5% {
transform: rotate(45deg);
}
25% {
transform: rotate(90deg);
}
37.5% {
transform: rotate(135deg);
}
50% {
transform: rotate(180deg);
}
62.5% {
transform: rotate(225deg);
}
75% {
transform: rotate(270deg);
}
87.5% {
transform: rotate(315deg);
}
100% {
transform: rotate(360deg);
}
}
Step 4 - 1
3초 이후, 입력창과 버튼이 최초 모습으로 바뀐다.
waitSubmit의 finally에서 message만 빈 문자열로 바꿔주면 됩니다.
<form
className="input-form"
onSubmit={(e) => {
e.preventDefault();
setIsWait(true);
waitSubmit().finally(() => {
setIsWait(false);
setMessage("");
});
}}
>
...
Step 4 - 2
3초 이후, 입력된 글자로 메시지가 오른쪽에 새롭게 등장한다.
5. State로 리렌더링하기 글에서 트리(tree) 구조를 소개했었는데,
마찬가지로 Box와 Input은 다음과 같은 구조를 가집니다.
사실 위에서 아래로 값을 전달하는 건 Header, Message 예시를 통해 많이 했었지만,
아래에서 위로 어떤 액션을 만들어주는 건 생소할 수 있습니다.
이 상황에서는 콜백 함수를 props로 받고 호출해주면 됩니다.
export default function Input({ onSubmit }: { onSubmit: (message: string) => void }) {
...
}
메시지가 입력된 글자로 새롭게 등장하려면,
외부에 어떤 메시지를 썼는 지 알려줘야 하므로 콜백 함수에 파라미터로 message를 만들어줬습니다.
이렇게 만들어주면 Box에서는 아래와 같이 콜백 함수를 만들어 전달해줄 수 있습니다.
간단한 예시로 alert를 해보면,
export default function Box() {
return (
<div className="layout">
<div className="box">
...
<Input onSubmit={(message: string) => alert(message)} />
</div>
</div>
);
}
다음과 같이 Box로 전달된 message가 잘 등장합니다.
이제 Box에게 새로운 메시지를 받을 state를 추가로 만들어주고
const [newMessages, setNewMessages] = useState<string[]>([]);
onSubmit 함수로 newMessages에 인자로 받은 message를 추가해주도록 합니다.
<Input onSubmit={(message: string) => setNewMessages([...newMessages, message])} />
그리고 마지막으로 newMessages를 순회하여 Message를 만들어줍니다.
<Screen>
<Message message="checked message" checked isOutgoing={false} />
<Message message="first message!" isOutgoing />
<Message message="second message~" isOutgoing />
{newMessages.map((message) => (
<Message message={message} isOutgoing />
))}
</Screen>
완성된 컴포넌트 코드는 다음과 같습니다.
import { useState } from "react";
import "./Box.scss";
import Header from "./Header";
import Input from "./Input";
import Message from "./Message";
import Screen from "./Screen";
export default function Box() {
const [newMessages, setNewMessages] = useState<string[]>([]);
return (
<div className="layout">
<div className="box">
<Header name="Cherry" />
<Screen>
<Message message="checked message" checked isOutgoing={false} />
<Message message="first message!" isOutgoing />
<Message message="second message~" isOutgoing />
{newMessages.map((message) => (
<Message message={message} isOutgoing />
))}
</Screen>
<Input onSubmit={(message: string) => setNewMessages([...newMessages, message])} />
</div>
</div>
);
}
Snapshot
여기서 문득 finally에게 전달한 콜백 함수를 보면
waitSubmit().finally(() => {
setIsWait(false);
setMessage("");
onSubmit(message);
});
message를 빈 문자열로 만들고, onSubmit 함수를 호출했는데,
어떻게 정상적으로 Box에게 "hello"를 전달할 수 있었을까요?
그 이유는 state들을 스냅샷(snapshot)으로 찍기 때문입니다.
예를 들어 우리가 입력창에 "hello"까지 작성했을 때, 이를 스냅샷으로 찍어서 컴포넌트에 전달합니다.
그래서 우리가 눈으로 보는 것처럼 "hello"로 렌더링이 되는 것이죠.
그리고 보내기 버튼을 눌렀을 때, form에 있던 onSubmit 함수의 로직이 실행되면 일전에 찍은 hello가 사용됩니다.
즉, 아래는 현재 시점에서 찍은 hello가 사용됩니다.
waitSubmit().finally(() => {
setIsWait(false);
setMessage(""); // message would be blank in next time.
onSubmit(message); // so, currently message is "hello".
});
그래서 Box는 hello를 공손히 받아 newMessages에 추가하는 것이죠.
<Input onSubmit={(message: string) => setNewMessages([...newMessages, message])} />
결국 newMessages에는 hello가 있으니, 아래처럼 Message를 만들 때 hello가 쓰입니다.
<Screen>
...
{newMessages.map((message) => ( // hello!
<Message message={message} isOutgoing />
))}
</Screen>
그리고 setMessage("")는 빈 값을 스냅샷으로 찍어 다음 렌더링을 위해 컴포넌트에 다시 전달하겠죠.
재밌게도 스냅샷은 고정(fixed)되어있기 때문에 아래처럼 alert에 지연시간을 준다 한들 어차피 hello가 표현됩니다.
<Input
onSubmit={(message: string) => {
...
setTimeout(() => alert(message), 1000);
}}
/>
References
'리액트 (React) > 리액트 레시피 (React Recipt)' 카테고리의 다른 글
8. Context 전달하기 (0) | 2024.06.12 |
---|---|
7. State 보존하기 (0) | 2024.06.11 |
5. State로 리렌더링하기 (0) | 2024.06.10 |
4. CSS 입히기 (0) | 2024.06.10 |
3. 조건부 렌더링 사용하기 (0) | 2024.06.09 |