기본적으로 HTML태그중 하나인 form에는 action이라는 속성이 존재한다.
원래 form의 action은 form data를 서버로 보낼 때 해당 데이터가 도착할 URL을 명시하는 용도로 사용되지만, 리액트 19버전부터는 함수를 사용하여 폼 제출을 처리하는 것이 가능해졌다.
export default function Signup() {
function signupAction(formData: FormData) {
}
return <form action={formAction}></form>;
}
위와 같이 form의 action을 통해 함수를 전달하면 자동으로 formData가 해당 함수에 넘어가게 된다.
이전에 e.preventDefault()를 통해 자동 새로고침을 막았었는데, 폼 액션을 사용하면 새로고침이 되지 않는다.
폼 액션의 결과를 기반으로 State를 업데이트할 수 있는 hook이다.
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
주요 매개변수는 다음과 같다.
fn: 폼이 제출되거나 버튼이 눌릴 때 호출되는 함수로, 해당 함수의 첫번째 인수로는 이전 state가 전달되고 그 뒤에 폼 액션의 인수가 전달된다.initialState: state의 초기값으로, 폼 액션이 한 번 호출된 후에는 사용되지 않는다.import { useActionState } from "react";
import { hasMinLength, isEmail, isEqualToOtherValue, isNotEmpty } from "../util/validation";
import { formKeys, SignupKeyTypes } from "../types/types";
export default function Signup() {
function signupAction(_: { errors: string[] | null }, formData: FormData) {
const getField = <T extends SignupKeyTypes>(key: T) => formData.get(formKeys[key]) as string;
const email = getField("email");
const password = getField("password");
const confirmPassword = getField("confirmPassword");
const firstName = getField("firstName");
const lastName = getField("lastName");
const role = getField("role");
const acquisition = formData.getAll(formKeys["acquisition"]) as string[];
const terms = getField("terms");
const errors = [];
if (!isEmail(email)) {
errors.push("Invaild email address.");
}
if (!isNotEmpty(password) || !hasMinLength(password, 6)) {
errors.push("You must provide a password with at least six characters.");
}
if (!isEqualToOtherValue(password, confirmPassword)) {
errors.push("Passwords do not match.");
}
if (!isNotEmpty(firstName) || !isNotEmpty(lastName)) {
errors.push("Please provide both your first and last name.");
}
if (!isNotEmpty(role)) {
errors.push("Please select a role.");
}
if (!terms) {
errors.push("You must agree to the terms and conditions.");
}
if (acquisition.length === 0) {
errors.push("Please select at least one acquisition channel.");
}
if (errors.length > 0) {
return {
errors,
enteredValues: {
email,
password,
confirmPassword,
firstName,
lastName,
role,
acquisition,
terms
}
};
}
return { errors: null };
}
const [formState, formAction] = useActionState(signupAction, { errors: null });
return (
<form action={formAction}>
<h2>Welcome on board!</h2>
<p>We just need a little bit of data from you to get you started 🚀</p>
<div className="control">
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" defaultValue={formState.enteredValues?.email} />
</div>
<div className="control-row">
<div className="control">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
name="password"
defaultValue={formState.enteredValues?.password}
/>
</div>
<div className="control">
<label htmlFor="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
name="confirm-password"
defaultValue={formState.enteredValues?.confirmPassword}
/>
</div>
</div>
<hr />
<div className="control-row">
<div className="control">
<label htmlFor="first-name">First Name</label>
<input
type="text"
id="first-name"
name="first-name"
defaultValue={formState.enteredValues?.firstName}
/>
</div>
<div className="control">
<label htmlFor="last-name">Last Name</label>
<input
type="text"
id="last-name"
name="last-name"
defaultValue={formState.enteredValues?.lastName}
/>
</div>
</div>
<div className="control">
<label htmlFor="phone">What best describes your role?</label>
<select id="role" name="role" defaultValue={formState.enteredValues?.role}>
<option value="student">Student</option>
<option value="teacher">Teacher</option>
<option value="employee">Employee</option>
<option value="founder">Founder</option>
<option value="other">Other</option>
</select>
</div>
<fieldset>
<legend>How did you find us?</legend>
<div className="control">
<input
type="checkbox"
id="google"
name="acquisition"
value="google"
defaultChecked={formState.enteredValues?.acquisition.includes("google")}
/>
<label htmlFor="google">Google</label>
</div>
<div className="control">
<input
type="checkbox"
id="friend"
name="acquisition"
value="friend"
defaultChecked={formState.enteredValues?.acquisition.includes("friend")}
/>
<label htmlFor="friend">Referred by friend</label>
</div>
<div className="control">
<input
type="checkbox"
id="other"
name="acquisition"
value="other"
defaultChecked={formState.enteredValues?.acquisition.includes("other")}
/>
<label htmlFor="other">Other</label>
</div>
</fieldset>
<div className="control">
<label htmlFor="terms-and-conditions">
<input
type="checkbox"
id="terms-and-conditions"
name="terms"
defaultChecked={!!formState.enteredValues?.terms}
/>
I agree to the terms and conditions
</label>
</div>
{formState.errors && (
<ul className="error">
{formState.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<p className="form-actions">
<button type="reset" className="button button-flat">
Reset
</button>
<button className="button">Sign up</button>
</p>
</form>
);
}
위와 같이 사용한다.
signupAction함수는 useActionState의 첫번째 인수로 넘겨지기 때문에 첫번째 인수로 해당 함수의 반환값의 상태를 받는다.
실제로 form의 action에 연결되는 함수는 useActionState의 반환값인 formAction함수를 연결한다.
기본적으로 form action은 submit시에 입력값들이 초기화 되기 때문에 해당 초기화를 방지하기 위해서는 useActionState의 반환값중 하나인 state를 통해 defaultValue값을 이전 상태값으로 지정하는것을 통해 해결할 수 있다.
하지만 현재 오류로 select태그의 경우 defaultValue를 지정해도 초기화되어버린다.
현재 action함수에 해당하는 signupAction함수는 꼭 컴포넌트 내부에 있을 필요는 없다.
만약 컴포넌트 내부의 값과 상호작용 하는 경우는 컴포넌트 내부에 존재해야 하지만, 그런 경우가 아니라면 외부로 빼두는 것이 최적화 측면에서 좋다.
만약 폼 액션에서 비동기 처리를 할 경우 해당 작업이 끝날때까지 UI를 바꿔야 할 상황이 있다.
이럴때 현재 폼 액션이 진행중인지에 대한 상태를 처리하기 위해 크게 2가지 방법이 존재한다.
useActionState의 반환값중에 3번째로 오는 값은 현재 폼 액션이 완료되었는지에 대한 pending boolean값이 반환된다.
따라서 해당 값을 사용하여 pending상태 처리가 가능하다.
useFormStatus는 마지막 폼 제출의 상태 정보를 제공하는 hook으로 다음과 같이 사용된다.
const { pending, data, method, action } = useFormStatus();
매개변수는 갖지 않으며, 반환값은 다음과 같다.
pending: boolean값으로, true는 상위 폼이 제출 중이라는 것을 의미한다.data: FormData를 구현한 객체로, 상위 폼이 제출하는 데이터를 포함한다.method: 'get' 또는 'post'중 하나로, 상위 폼의 method값을 나타낸다.action: 상위 폼의 action에 전달한 함수의 레퍼런스이다.낙관적 업데이트란 백그라운드 작업이 끝나기 전에 사용자 인터페이스를 먼저 업데이트해주는 것으로, 사용자 경험과 관련된 개념이다.
예를들어 추천 또는 비추천 버튼을 누르고 백엔드에서 해당 작업이 완료될때까지 2초가 걸린다 해보자.
그렇다면 사용자는 2초가 지난 후에 UI가 업데이트된것을 확인할 수 있을것이다.
하지만 낙관적 업데이트를 구현하면 버튼을 누르는 즉시 UI가 업데이트되며, 만약 백엔드에서 오류가 발생하여 실행이 되지 않았을 경우 다시 원래상태로 복원된다.
react에서는 이러한 낙관적 업데이트를 useOptimistic이라는 hook을 통해 구현할 수 있다.
UI를 낙관적 업데이트할 수 있게 해주는 hook으로, 다음과 같이 사용한다.
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
매개변수는 다음과 같다.
state: 작업이 대기중이 아닐 때 초기에 반환될 값updateFn(currentState, optimisticValue): 현재 상태와 반환값인 addOptimistic에 전달된 낙관적인 값을 갖는 함수로, 낙관적인 상태를 반환한다.optimisticState: 결과적인 낙관적 상태로, 작업이 대기중일때는 updateFn에서 반환된 값과 같으며, 그렇지 않을 경우 state와 같다.addOptimistic: 낙관적 업데이트가 있을 때 호출하는 dispatch함수이다.const [optimisticVotes, setVotesOptimistically] = useOptimistic(
votes,
(prevVotes, mode: "up" | "down") => (mode === "up" ? prevVotes + 1 : prevVotes - 1)
);
async function upvoteAction() {
setVotesOptimistically("up");
await upvoteOpinion(id);
}
async function downvoteAction() {
setVotesOptimistically("down");
await downvoteOpinion(id);
}
위와 같이 사용하며, addOptimistic함수는 비동기 코드 이전에 와야한다.