해당 문서는 react-router v6.4 의 action, loader를 중점으로 기술되어 있습니다.
react-router v6.4의 new features를 적극적으로 도입해라! 라기 보다,
'대신 문서 읽어주는 것' 을 목적으로 작성했으며,
신규 프로젝트를 구성하며 도입했고, 도입을 시도했던 것들에 대한 경험에서 느꼈던 주관적인 견해가 담겨져 있습니다.
form은 사용자가 입력한 값을 어딘가로 보내기 위한 수단이다.
프론트엔드, 백엔드라는 고유명사없이 웹 개발자 로 퉁쳐지던, 한참 컴퓨터 공학은 3D 업계다 라고 하던 시절엔 서버가 정적 문서도 서빙하고, API 호출, 데이터 제어 등등을 했을 거다.
아마도 그 때에도 form 태그는 적극적으로 사용되고 있었을 것이다.
예제 코드를 살펴보자.
<form method="get">
<input name="email" />
<input name="password" />
</form>
<form method="post">
<input name="email" />
<input name="password" />
</form>
<form action={something_url} method="post">
<input name="email" />
<input name="password" />
</form>
위의 예시처럼 단순한 입력을 위한 페이지를 만드는 것은 매우 쉽다. 서버사이드와 붙여보자.
- 사용자가 / 에 접근하면 서버는 해당 경로를 찾아 정적 문서를 서빙한다.
- 사용자는 이메일과 비밀번호를 입력하고 엔터키를 누른다.
- form 태그는 /login 을 향해 이메일, 비밀번호를 담아 요청을 보낸다.
- 서버는 /login이면서 method가 post인 경로를 찾고, 해당하는 문서를 서빙한다.
이 과정을 직렬화하여 표현하면 아래와 같다.
렌더링 -> form action -> server -> { server do something... } -> 렌더링
⛔⛔⛔
SPA는 정적 어플리케이션이기 때문에 get 요청 외에는 받을 수 없다.
따라서 정적 문서에 post로 action을 보내면 405 method error를 마주할 수 있다.
위와 같은 이유로, 서버 요청이 필요한 경우 보통 아래와 같은 흐름으로 컴포넌트를 만든다.
즉, 이 컴포넌트는 렌더링, 라우팅, 상태관리, API 호출을 담당하는 1코어 4스레드가 되었다.
ㅋㅋ 사람이 어떻게 팔이 4개임?
... 사실 두 손 두 발 다 써서 합쳐서 4개 맞음 ;;
UI만 신경쓴다면서요 ㅠㅠ
위와 같이 하나의 컴포넌트가 너무 많은 책임을 지고 있는 것에 대해, react-router v6는 전통적인 form action을 본딴 기능인 action, loader를 소개한다.
v6.4의 신기능을 사용하려면 기존의 라우터를 걷어내고 새로운 방식을 사용해야된다.
기존 방식
변경된 방식
newRouter를 router props에 풀어다써도 된다. type declaration을 보니 children 형태로 쓰는 건 지원되지 않는다.
위의 라우터 코드는 아래와 같은 흐름으로 동작한다.
- / 에는 /login으로 navigate 시키는 버튼이 있다.
- /login 렌더링 후, 사용자는 내부 form을 입력하고 제출한다.
- loginAction 함수가 호출되어 전달받은 formData로 무언가를 하고 /main 으로 redirect한다.
- /main이 렌더링되기 전, loader 함수가 전처리 작업을 수행한다.
- /main이 렌더링된다.
실제로 Login, Main 컴포넌트에서는 navigate 동작을 전혀 하지 않고 있고, API를 호출하거나 하지도 않는다. 이런 방식으로 UI 컴포넌트가 form 입력에 따른 라우트 제어에 대한 책임에서 벗어났다.
물론, Login 컴포넌트에 있는 form data를 다루기위해 useState를 사용할 필요도 없다.
그러면 위와 같은 것이 어떻게 되는가? 를 알아보기위해 Action과 Loader를 소개해본다.
Login 컴포넌트는 이렇게 생겼다.
여기서 Form 은 react-router에서 제공하는 별도의 컴포넌트다.
사용자가 데이터를 입력하고, submit 행위를 하면 '/login' path에 연결된 action 함수를 호출한다.
⛔⛔⛔
앞서 본 것처럼, action을 사용을 위해 Route 컴포넌트의 action prop에 함수를 끼워주자.
loginAction은 이렇게 생겼다.
안타깝게도 타입이 추론되지는 않아서 type assert가 필요하다.
고맙게도 JSON.뭐시기를 쓸 필요없이 fromEntries로만 파싱해 올 수 있다.
이 함수에서 전달받은 formData를 이용해 필요한 동작을 하면 된다.
request
- 실제 해당 action이 가리키는 URL에 요청된 Request를 의미한다.
- method='post'라고 해서 실제 문서에 post 요청을 쏘는 것이 아니다. 아마도 RouterProvider에서 알잘딱 하고 있는 것 같다.
- 실제로 request. 해서 어떤 프로퍼티가 들어있다 보면 MDN에 정의된 그대로 다~ 들어있다.
redirect
- react-router v6.4가 제공하는 redirect 함수.
- 사실은 리소스의 Response를 추상화한 것이라고 한다.
302 코드는 ‘요청된 리소스가 새로운 location으로 이동했음’을 의미한다.
redirect 함수를 쓸 필요 없이, 직접 위와 같이 구성해서 return 해도 된다.
실제로 비즈니스에 사용된다면, 이런식으로 사용될 것 같다.
catch 블럭의 경우, redirect되지 않고 에러 메세지를 반환하고 있다.
컴포넌트는 이를 ActionData로서 받아 무언가 후처리를 할 수 있다.
한편, 꼭 redirect를 할 필요는 없을 수 있다.
예를 들어 현재 페이지를 업데이트하기 위해 action이 사용될 수도 있는 법이다.
해당 경우는 좀 더 아래에서 다뤄본다.
action의 용례와 엮어본다면 위와 같이 만들어볼 수 있겠다.
UI가 렌더링되기 전, loader 함수가 실행된다.
loader는 action과 마찬가지로 API를 호출할 수도 있고, 또는 임의의 무언가를 할 수 있다.
중간에 보면 특정 조건에 따라 타겟 URL이 아닌 다른 URL로 redirect 처리를 해버릴 수도 있다.
마지막으로, 딱히 loader가 담당해야될 것이 없다면 아예 사용하지 않아도 상관없다.
⛔⛔⛔
앞서 본 것처럼, loader의 사용을 위해 Route 컴포넌트의 action prop에 함수를 끼워주자.
request
- action 함수의 request와 같다.
params
- /profile/:id 와 같은 형태일 때, params.id로 획득할 수 있다.
return userInfo
- action에서 redirect를 꼭 할 필요가 없듯이, loader 또한 null 뿐만 아니라 데이터도 반환할 수 있다. 마찬가지로, 컴포넌트에서 LoaderData로서 취급하여 가지고 올 수 있다.
useActionData
- action 함수에서 반환된 값
- 초기 값은 action이 수행되지 않았기에 undefined다. 위에서 언급한 것처럼, redirect를 쓰지 않고 임의의 무언가를 반환할 때 그 후처리를 위해 사용할 수 있다.
useLoaderData
- loader 함수에서 반환된 값
- loader가 반환되는 값이 있다면 undefined는 아니다. 다만, 타입 추론은 안되어서 필요하다면 type assertion을 해야된다.
먼저 Router를 만들고..
Login, loginAction을 만들고
Main, mainLoader를 만들어서 값을 받자.
Login 렌더링 → form action → loginAction 호출 → mainLoader 호출 → Main 렌더링
원래대로였으면 Login과 Main 컴포넌트가 뚱뚱해졌을 것이 책임분리를 통해 다이어트된 것이 확실하게 보인다!
이런식으로 만들어주면 좀 더 이쁘다.
SSAP POSSIBLE !
action이 어딘가로 이동시키기 전에 동작하는 것이 아닌, '정보를 제출하는 행위' 로만 생각해보자.
새로고침을 떠올려보자.
이렇게 하면 action과 loader를 동시에 사용하여 현재 페이지에 있는 값을 업데이트할 수 있다.
그렇지 않지. 킹갓 react-router v6.4는 새로고침하는 hook도 있어.
useRevalidator는 실제로 페이지를 새로고침하는 것이 아닌, loader를 다시 호출한다.
사용법은 위와 같으며, 추가로 state 를 제공한다.
state
- isIdle : 유휴 상태. 아무것도 안하고 있을 때.
- loading : 예를 들어서, 만약 loader가 API 호출을 하고 있으면 호출이 끝날 때까지 이 상태가 유지된다. loader가 동작하고 있으면서, loaderData가 undefined인 경우일 것이다.
여기까지 알아봤을 때, 내심 생각한 것은
⭕ 결론!
- React? UI를 위한 라이브러리니까 UI만 책임지자.
- 요청과 응답을 처리하기 위해 React를 쓰는 것인가? 아니다.
- 오히려 데이터를 제어하기 위해 많은 useState를 정의하고, API를 직접 호출하는 등의 코드를 만들던 것이 부조리한 boilerplate 아니었을까?
- 내가 익숙함에 속고 있었던 것은 아니었을까?
💢 그러나..
- loader는 결국 렌더링 이전에 수행되기 때문에 페이지 이동이 약간 끊기는 듯한 현상이 일어난다.
- 또한 React에는 이미 효과적인 라이브러리들 (tanstack/query, react-hook-form 등) 이 있기 때문에, react-router 로만 코드가 구성될 수는 없다.
💡 한편?
- 프레임워크처럼 무조건 써야해! 딴 거 쓰지마! 하고 있는 것이 아니라, ‘편할 대로 써~’ 같은 입장으로 만들어진 것 같은 게 매력적이다.
- 무엇보다 이 기능들은 기존의 무언가를 대체하기 위해 나온 것이 아니다. 이후 레퍼런스와 함께 설명한다.
예시를 들면, 위의 이미지와 같이 꼭 그들이 제공하는 Form 컴포넌트를 쓸 필요는 없다.
또 네이티브 form을 쓰지 않고, 그냥 버튼에다가 onSubmit을 달고 useSubmit의 첫번째 인자엔 e.currentTarget을 어떻게든 조합해서 보내줘도 된다. (물론 비효율적이겠지만)
요청된 URL Path가 없다 → new Response({status:404}) 이므로, 404 Not Found가 렌더링된다.
401, 403에 대해서도, 서버의 응답코드로 대응될 수도 있는 거고.
혹은 개발자가 throw new Response~ 같은 방식으로도 할 수 있다.
다만 어디까지나 Route cycle 내부의 Response로 판단하기 때문에, 별도의 ErrorBoundary가 필요할 수 있다.
Dynamic import를 통한 lazy loading도 지원한다.
한편, react-router의 lazy를 사용하는 경우는 export default를 사용할 수 없고 함수명을 위와 같이 그대로 써야만한다.
그래도 displayName을 지정해주면 devTools에서 지정된 이름으로 표시된다고 한다.
또한 문서에 따르면 code-splitting을 통한 번들 사이즈 다이어트가 가능하다고 명시되어있다.
예를 들어 이렇게 하면,
최초 랜딩된 지점의 스크롤 위치를 기억하고, 라우트가 이동될 때 자동으로 스크롤 위치를 복원한다.
복원될 스크롤 위치를 지정할 수도 있는데, 기본값은 location.key이다.
location.key의 경우 window를 기준으로 스크롤 복원을 한다고 되어있고,최초에 랜딩됐던 path의 스크롤 위치를 기준으로 하고 싶다면 pathname을 반환하라고 하는 것 같은데..
사실 정확히 잘 모르겠다. 😇
특정 Path에서는 스크롤 복원을 원치않을 수 있다. 이 경우, 이런식으로 대응해야된다.
따라서, ScrollRestoration은 당장 쓰기엔 힘들 것 같다.
뭔가 usePreventScrollReset() hook이 있어서 복원하기 싫은 컴포넌트에 박아주면 알아서 방지해주면 좋으련만...
한편으로는 react-router는 컴포넌트 내부 렌더 사이클이 일어나기 전까지의 동작을 담당하기 때문에 그런 형태의 것을 제공하기 어렵겠구나, 라는 생각이 들긴한다.
이렇게 하면 특정 부분은 데이터가 불러온 뒤에 렌더링되고,
loader에서 에러가 난 경우 Await의 errorElement가 렌더링된다.
이러한 기능은 지역적인 ErrorBoundary를 대체할 수 있을지도..? 라는 생각을 하게 된다.
react-router는 데이터를 가지고 무엇인가를 하는 것이 아니라, 특정 시점에 무엇을 할거냐? 라는 것에 중점을 둔다.
즉, react-query의 그것과는 결이 다르다!
어라? 그러면 loader에서 최초 쿼리 캐시를 주입하면 되는 거 아닌가?
이런식으로 쿼리키가 없으면 페칭하도록 해서 컴포넌트가 초기값을 불러올 때 깜빡거리는 것을 막을 수 있다.
또한 useMutation 대신 action을 쓰고,
action 함수 내부에서 queryClient.invalidateQueries를 이용한다면
값의 업데이트 시점마저 UI 컴포넌트가 책임지지 않도록 할 수 있다.
다만 생각보다 action → loader 시 페이지 최초 로딩에 지연 시간이 긴게 체감이 되다보니
프로젝트에 도입할 때는 action은 폐기하고, loader도 필요한 곳에 대해서만 쓰게 됐다.
예를 들면 AuthProvier를 대체하는 authLoader가 존재한다.
사용자가 첫 진입을 했든, 혹은 새로고침을 한 경우에 대해 인증/인가에 대한 검사를 수행하는 용도이다.
react-hook-form과의 연동 용례는 공식적으로 가이드되진 않으나, action과 함께 사용하는 용례가 존재한다.
react-router의 Form, action이 submit에 대한 책임을 지고, react-hook-form은 validation에 관여하는 것이다.
위에 있는 react-hook-form의 discussion에 달린 댓글로 끝맺음을 해보려고 한다.
react-router는 굳이 자바스크립트로 표현할 필요가 없을 서버의 동작과 폼 데이터를 원활하게 통합한다.
React는 UI를 위한 라이브러리이지, 프레임워크가 아니다.
생각해보자, 과연 내 컴포넌트가 역할 이상의 것을 하고 있는 것은 아닐까?