리액트 18버전에서는 Automatic batching을 이용해 여러가지 동작을 하나로 묶어서 리렌더링을 처리한다. batching이라는 게 그런걸 의미하는데 그걸 자동적으로 해준다는 것이고 18버전 이전에는 api 호출에 콜백으로 넣은 함수나 timeouts 함수에서는 동작하지 않았다.
하지만 batch 처리를 자동적으로 하고 싶지 않은 상황이 있을 수도 있다. 그때는 flushSync를 사용해서 일괄 처리를 해제 할 수 있다. 예를 들어, 사용자 입력을 처리하는 동안 입력 필드의 유효성을 즉시 검사하고 피드백을 제공해야 하는 경우가 있다.
import { useState } from 'react'
import { flushSync } from 'react-dom'
function UsernameInput() {
const [username, setUsername] = useState('')
const handleChange = (event) => {
const newValue = event.target.value
// flushSync를 사용하여 상태 업데이트를 즉시 처리
flushSync(() => {
setUsername(newValue)
})
// 이 시점에서 username 상태가 이미 업데이트되었고, 연관된 UI도 즉시 리렌더링됨
validateUsername(newValue) // 즉시 유효성 검사 수행
}
return <input type="text" value={username} onChange={handleChange} />
}
서버 사이드 환경에서도 Suspense를 사용할 수 있게 되었다. Suspense란, 비동기로 처리되는 작업에 대해 로딩상태를 보다 쉽게 관리할 수 있게 해주는 기능이다.
Suspense의 주요 사용 사례
로딩 상태 표시: 데이터를 비동기적으로 로드하는 동안 사용자에게 로딩 인디케이터(예: 스피너, 진행 바 등)를 보여줄 수 있다.
import React, { Suspense } from 'react'
const OtherComponent = React.lazy(() => import('./OtherComponent'))
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
)
}
코드 스플리팅: React.lazy
와 함께 사용하여 컴포넌트를 동적으로 로드하고, 컴포넌트가 로드될 때까지 로딩 플레이스홀더를 보여준다.
import React, { Suspense } from 'react'
const LazyComponent = React.lazy(() => import('./LazyComponent'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)
}
데이터 페칭: 비동기적으로 데이터를 페칭하고 처리하는 컴포넌트 라이브러리와 함께 사용하여, 데이터 로딩 상태를 우아하게 관리할 수 있다.
import React, { Suspense } from 'react'
import { fetchData } from './api'
const resource = fetchData()
function DataComponent() {
const data = resource.read()
return <div>{data}</div>
}
function MyComponent() {
return (
<Suspense fallback={<div>Loading data...</div>}>
<DataComponent />
</Suspense>
)
}
즉각적으로 반영되기 바라는 UI와 아닌 UI를 구분지어서 관리해주는 기능
startTransition
import { startTransition } from 'react'
function TabContainer() {
const [tab, setTab] = useState('about')
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab)
})
}
// ...
}
useTransition
startTransition에 적용된 부분이 pending 상태일 때를 관리해주는 hook
const [isPending, startTransition] = useTransition()
Form과 관련된 컴포넌트들에서 UI와 관련된 로직들을 최대한 UI만 관리할 수 있도록 하고 비즈니스 로직을 담고 있는 컴포넌트를 분리해서 관리하고 싶었다. (물론 UI와 관련된 공통 컴포넌트들 중에 무조건 UI만 가지고 있게 할 수는 없지만, 적어도 Form을 전체적으로 그리는 컴포넌트에서는 UI만 가지고 있을 수 있도록 하고 싶었다.
그래서 UI를 담고 있는 컴포넌트를 FormView라고 명명하고 분리하는 과정을 정리해보려고 한다.
일단 Form은 대부분 Input이 존재한다. 그리고 리액트에서 input이 존재한다는 건 onChange 이벤트를 감지해서 상태 변경을 지속적으로 피드백 받거나 ref를 이용해서 입력값을 submit 하는 등으로 활용한다. 어떤 방법이든 함수 컴포넌트에서 훅을 사용해야 하고, 훅을 사용한다는 건 UI 로직만 담을 수 없다는 걸 의미한다. 그렇게 하나의 컴포넌트에서 form을 전체적으로 관리하다보면 비즈니스 로직과 UI로직이 같이 담겨있게 된다. 그럼 FormView라는 컴포넌트를 두고 상위 컴포넌트에서 비즈니스로직을 담고 있는 다음에 props로 내려줘도 되지만 컴포넌트 깊이가 깊어지면 props drilling 문제가 발생한다. 그래서 이 부분을 해결하기 위해 FormContext를 선언해서 원하는 곳에서 form과 관련된 formValue를 가져와서 사용할 수 있도록 해봤다.
'use client';
export const formContext = createContext<FormValue | null>(null);
const FormContext = ({
id,
className,
children,
...useFormProps
}: FormContextProps) => {
const formValue = useForm(useFormProps);
return (
<formContext.Provider value={formValue}>
<form id={id} className={className} onSubmit={formValue.handleSubmit}>
{children}
</form>
</formContext.Provider>
);
};
export default FormContext;
'use client'
const Register = () => {
const router = useRouter()
const handleSubmit = () => {
router.push('/login')
}
return (
<AuthPage
header={<AuthHeader />}
divider={<AuthDivider />}
footer={<OAuthFooter />}
>
<span className="text-xl xs:text-[10px]">단계 1/2</span>
<FormContext
formType="register"
id="register-form"
className=""
validate={validationFunctions}
onSubmit={handleSubmit}
>
<RegisterForm />
</FormContext>
</AuthPage>
)
}
export default Register
외부 페이지에서 FormContext를 씌위서 그 아래에 있는 컴포넌트에서 formValue를 사용할 수 있게 했다. 회원가입의 경우 isEnterUserInfo의 값에 따라 보여줘야 하는 컴포넌트가 달랐기 때문에 RegisterFormView 외부에 하나의 컴포넌트를 더 두게 되었다.
'use client'
const RegisterForm = () => {
const { handleClickContinue, isEnterUserInfo } = useContext(
formContext
) as FormValue
return (
<>
<RegisterFormView
isEnterUserInfo={isEnterUserInfo}
handleClickContinue={handleClickContinue}
/>
</>
)
}
export default RegisterForm
이렇게 하면 RegisterFormView에서는 UI로직만 가지고 있을 수 있게 되고 input과 관련된 AuthInput에서도 FormContext에 선언한 formValue 중에 handleChange 값을 가지고 와서 사용하면 된다. 물론 그렇다 보니 서버 컴포넌트가 되고 ‘use client’를 선언할 필요가 없다.
const RegisterFormView = ({
isEnterUserInfo,
handleClickContinue
}: FormValue) => {
return (
<>
{!isEnterUserInfo && (
<>
<h1 className="my-6 text-3xl xs:text-xl">회원가입</h1>
<AuthInput
type="text"
placeholder="아이디를 입력해주세요"
name="email"
/>
<ValidationMessages name="email">
<ValidationMessage text="이메일을 형식에 맞게 입력해주세요." />
</ValidationMessages>
<AuthInput
type="password"
name="password"
placeholder="비밀번호를 입력해주세요"
/>
<ValidationMessages name="password">
<ValidationMessage text="영문/숫자/특수문자 2가지 이상 조합 (8~20자)" />
<ValidationMessage text="3개 이상 연속되거나 동일한 문자/숫자 제외" />
</ValidationMessages>
<AuthInput
type="text"
name="name"
placeholder="이름을 입력해주세요"
/>
<ValidationMessages name="name">
<ValidationMessage text="이름을 정확히 입력하세요. (2글자 이상, 숫자 제외)" />
</ValidationMessages>
<AuthInput
type="text"
name="phone"
placeholder="휴대폰 번호를 입력해주세요"
/>
<ValidationMessages name="phone">
<ValidationMessage text="휴대폰 번호를 정확하게 입력하세요. ( - 포함)" />
</ValidationMessages>
</>
)}
{isEnterUserInfo && (
<>
<TermsOfUse />
</>
)}
{!isEnterUserInfo && <Button text="계속" onClick={handleClickContinue} />}
{isEnterUserInfo && <Button text="가입하기" type="submit" />}
</>
)
}
export default RegisterFormView
AuthInput도 useContext로 formValue 중에 handleChange를 가지고 와서 사용하면 된다.
'use client'
const AuthInput = ({
type,
name,
placeholder,
style = STYLE_INPUT_DEFAULT,
id
}: AuthInputProps) => {
const { handleChange } = useContext(formContext) as FormValue
return (
<input
id={id}
type={type}
name={name}
placeholder={placeholder}
className={style}
onChange={handleChange}
/>
)
}
export default AuthInput
이렇게 관리하면 좀 더 선언적으로 관리할 수 있기 때문에 유지보수하는 데 좀 더 수월할 거라 생각한다. 그리고 물론 redux와 같은 상태관리 라이브러리를 사용할 수도 있었겠지만, form과 관련된 부분만 전달해서 사용할 것이기 때문에 굳이 라이브러리를 이용하지 않아도 context로 처리할 수 있다고 판단했다.