컴포넌트와 백엔드 코드를 함께 두는 이유(colocate)와 방법을 알아봅니다.
원문입니다 : https://www.epicweb.dev/full-stack-components
백엔드와 UI 코드의 환상적인 결합
remix를 활용할 때, 페이지를 개발하는 방법은 아래와 같습니다.
export async function loader({ request }: LoaderArgs) {
const projects = await getProjects()
return json({ projects })
}
export async function action({ request }: ActionArgs) {
const form = await request.formData()
// do validation 👋
const newProject = await createProject({ title: form.get('title') })
return redirect(`/projects/${newProject.id}`)
}
export default function Projects() {
const { projects } = useLoaderData<typeof loader>()
const { state } = useTransition()
const busy = state === 'submitting'
return (
<div>
{projects.map(project => (
<Link to={project.slug}>{project.title}</Link>
))}
<Form method="post">
<input name="title" />
<button type="submit" disabled={busy}>
{busy ? 'Creating...' : 'Create New Project'}
</button>
</Form>
</div>
)
}
- 클릭 이벤트 핸들러
- 요청 가져오기
- 오류
- 낙관적 UI
- 보류 상태
- 레이스 컨디션
- 상태 업데이트
- 백엔드 코드를 포함하는 API 경로
- 실제 데이터베이스의 데이터 업데이트를 처리합니다.
내 경험에 따르면 이 모든 작업에는 6개 이상의 파일이 포함되기도 하고
종종 2개의 리포지토리(때로는 여러 프로그래밍 언어도 포함됨)가 포함됩니다.
이 모든 것이 제대로 동작하도록 하려면 많은 작업이 필요합니다!
리소스 경로 만들기
소스코드(start here)를 이용해 직접 실습해 보실 수 있습니다.
// app/routes/resources/customers.tsx
import { json } from '@remix-run/node'
export async function loader() {
return json({ hello: 'world' })
}
{hello:"world"}
이제 request를 위해 호출할 수 있는 "엔드포인트"가 있습니다.
고객을 검색하기 위해 코드를 작성해 보겠습니다.
import type { LoaderArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import invariant from 'tiny-invariant'
import { searchCustomers } from '~/models/customer.server'
import { requireUser } from '~/session.server'
export async function loader({ request }: LoaderArgs) {
await requireUser(request)
const url = new URL(request.url)
const query = url.searchParams.get('query')
invariant(typeof query === 'string', 'query is required')
return json({
customers: await searchCustomers(query),
})
}
- 공개적으로 액세스할 수 있는 URL이지만 고객 데이터는 비공개여야 하므로 요청하는 사용자가 인증되었는지 확인하는 requireUser를 통해 보안을 유지해야 합니다.
- GET 요청이기 때문에 사용자 입력을 URL 쿼리 파라미터에서 가져옵니다(또한 쿼리가 예상대로 문자열인지 확인합니다).
- searchCustomers(query)를 사용하여 데이터베이스에서 일치하는 고객을 검색하고 JSON 응답으로 다시 보냅니다.
{
"customers": [
{
"id": "cl9putjgo0002x7yxd8sw8frm",
"name": "Santa Monica",
"email": "santa@monica.jk"
},
{
"id": "cl9putjgr000ox7yxy0zb3tca",
"name": "Stankonia",
"email": "stan@konia.jk"
},
{
"id": "cl9putjh0002qx7yxkrwnn69i",
"name": "Wide Open Spaces",
"email": "wideopen@spaces.jk"
}
]
}
좋습니다. 이제 엔드포인트에 요청할 수 있는 컴퐆넌트만 있으면 됩니다.
UI 컴포넌트 만들기
이 포스트에서 논의하고 있는 것은 그다지 중요하지 않기 때문에 모든 React/JSX 코드를 제공하겠습니다.
UI 컴포넌트의 시작은 다음과 같습니다.
import type { LoaderArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import clsx from 'clsx'
import { useCombobox } from 'downshift'
import { useId, useState } from 'react'
import invariant from 'tiny-invariant'
import { LabelText } from '~/components'
import { searchCustomers } from '~/models/customer.server'
import { requireUser } from '~/session.server'
export async function loader({ request }: LoaderArgs) {
await requireUser(request)
const url = new URL(request.url)
const query = url.searchParams.get('query')
invariant(typeof query === 'string', 'query is required')
return json({
customers: await searchCustomers(query),
})
}
export function CustomerCombobox({ error }: { error?: string | null }) {
// 🐨 implement fetcher here
const id = useId()
const customers = [] // 🐨 should come from fetcher
type Customer = typeof customers[number]
const [selectedCustomer, setSelectedCustomer] = useState<
null | undefined | Customer
>(null)
const cb = useCombobox<Customer>({
id,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedCustomer(selectedItem)
},
items: customers,
itemToString: item => (item ? item.name : ''),
onInputValueChange: changes => {
// 🐨 fetch here
},
})
// 🐨 add pending state
const displayMenu = cb.isOpen && customers.length > 0
return (
<div className="relative">
<input
name="customerId"
type="hidden"
value={selectedCustomer?.id ?? ''}
/>
<div className="flex flex-wrap items-center gap-1">
<label {...cb.getLabelProps()}>
<LabelText>Customer</LabelText>
</label>
{error ? (
<em id="customer-error" className="text-d-p-xs text-red-600">
{error}
</em>
) : null}
</div>
<div {...cb.getComboboxProps({ className: 'relative' })}>
<input
{...cb.getInputProps({
className: clsx('text-lg w-full border border-gray-500 px-2 py-1', {
'rounded-t rounded-b-0': displayMenu,
rounded: !displayMenu,
}),
'aria-invalid': Boolean(error) || undefined,
'aria-errormessage': error ? 'customer-error' : undefined,
})}
/>
{/* 🐨 render spinner here */}
</div>
<ul
{...cb.getMenuProps({
className: clsx(
'absolute z-10 bg-white shadow-lg rounded-b w-full border border-t-0 border-gray-500 max-h-[180px] overflow-scroll',
{ hidden: !displayMenu },
),
})}
>
{displayMenu
? customers.map((customer, index) => (
<li
className={clsx('cursor-pointer py-1 px-2', {
'bg-green-200': cb.highlightedIndex === index,
})}
key={customer.id}
{...cb.getItemProps({ item: customer, index })}
>
{customer.name} ({customer.email})
</li>
))
: null}
</ul>
</div>
)
}
백엔드 API 연결하기
Kody🐨의 지시에 따라 여기에 useFetcher를 추가하여 리소스 경로에 GET 요청을 합시다.
먼저 useFetcher를 만들고 고객 목록 데이터를 사용하겠습니다.
const customerFetcher = useFetcher<typeof loader>()
const id = useId()
const customers = customerFetcher.data?.customers ?? []
type Customer = typeof customers[number]
const [selectedCustomer, setSelectedCustomer] = useState<
null | undefined | Customer
>(null)
const cb = useCombobox<Customer>({
id,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedCustomer(selectedItem)
},
items: customers,
itemToString: item => (item ? item.name : ''),
onInputValueChange: changes => {
customerFetcher.submit(
{ query: changes.inputValue ?? '' },
{ method: 'get', action: '/resources/customers' },
)
},
})
보류 UI 추가하기
일부 사용자의 경우 이것으로 충분하지만 제어할 수 없는 네트워크 조건에 따라 이 요청에 시간이 걸릴 수 있습니다.
보류 중인 상태를 나타내는 UI를 추가해 보겠습니다.
<input /> 옆에 있는 JSX에서 🐨 가 스피너를 렌더링해야 한다고 알려줍니다.
다음은 스피너 컴포넌트 입니다.
function Spinner({ showSpinner }: { showSpinner: boolean }) {
return (
<div
className={`absolute right-0 top-[6px] transition-opacity ${
showSpinner ? 'opacity-100' : 'opacity-0'
}`}
>
<svg
className="-ml-1 mr-3 h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<circle
className="opacity-25"
cx={12}
cy={12}
r={10}
stroke="currentColor"
strokeWidth={4}
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)
}
input 옆에 렌더링해 보겠습니다.
<Spinner showSpinner={showSpinner} />
const showSpinner = customerFetcher.state !== 'idle'
UI 그리기
import { CustomerCombobox } from '~/routes/resources/customers'
error prop은 해당 포스트의 범위를 벗어나지만, 자유롭게 확인할 수 있습니다.
보류 상태 개선하기
UI가 렌더링되면 이제 모든 것이 작동합니다! 하지만 먼저 해결하고 싶은 작은 문제가 하나 있습니다.
로더에서 느린 네트워크를 시뮬레이션하기 위한 요청 속도를 늦추는 약간의 코드를 추가합니다.
export async function loader({ request }: LoaderArgs) {
await requireUser(request)
const url = new URL(request.url)
const query = url.searchParams.get('query')
await new Promise(r => setTimeout(r, 30)) // <-- add that
invariant(typeof query === 'string', 'query is required')
return json({
customers: await searchCustomers(query),
})
}
const busy = customerFetcher.state !== 'idle'
const showSpinner = useSpinDelay(busy, {
delay: 150,
minDuration: 500,
})
- 만약 제가 준 값이 false면, true로 바뀌더라도 150ms 동안은 false를 주세요. 만약 다시 false가 되면 계속 false를 주시면 돼요
- 만약 한번 true를 주게 되면, 제가 준 값이 다시 false가 되더라도 500ms 동안은 그냥 true를 주세요
처음에 설정한 로더의 타임아웃 딜레이를 250ms로 늘린 뒤 테스트해 보세요!
제가 보기엔 딱 좋습니다.
최종 버전
import type { LoaderArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useCombobox } from 'downshift'
import { useId, useState } from 'react'
import { useSpinDelay } from 'spin-delay'
import invariant from 'tiny-invariant'
import { LabelText } from '~/components'
import { searchCustomers } from '~/models/customer.server'
import { requireUser } from '~/session.server'
export async function loader({ request }: LoaderArgs) {
await requireUser(request)
const url = new URL(request.url)
const query = url.searchParams.get('query')
invariant(typeof query === 'string', 'query is required')
// 🐨
return json({
customers: await searchCustomers(query),
})
}
export function CustomerCombobox({ error }: { error?: string | null }) {
const customerFetcher = useFetcher<typeof loader>() //🐨
const id = useId()
const customers = customerFetcher.data?.customers ?? [] // 🐨
type Customer = typeof customers[number]
const [selectedCustomer, setSelectedCustomer] = useState<
null | undefined | Customer
>(null)
const cb = useCombobox<Customer>({
id,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedCustomer(selectedItem)
},
items: customers,
itemToString: item => (item ? item.name : ''),
onInputValueChange: changes => {
//🐨
customerFetcher.submit(
{ query: changes.inputValue ?? '' },
{ method: 'get', action: '/resources/customers' },
)
},
})
const busy = customerFetcher.state !== 'idle' // 🐨
const showSpinner = useSpinDelay(busy, {
delay: 150,
minDuration: 500,
})
const displayMenu = cb.isOpen && customers.length > 0
return (
<div className="relative">
<input
name="customerId"
type="hidden"
value={selectedCustomer?.id ?? ''}
/>
<div className="flex flex-wrap items-center gap-1">
<label {...cb.getLabelProps()}>
<LabelText>Customer</LabelText>
</label>
{error ? (
<em id="customer-error" className="text-d-p-xs text-red-600">
{error}
</em>
) : null}
</div>
<div {...cb.getComboboxProps({ className: 'relative' })}>
<input
{...cb.getInputProps({
className: clsx('text-lg w-full border border-gray-500 px-2 py-1', {
'rounded-t rounded-b-0': displayMenu,
rounded: !displayMenu,
}),
'aria-invalid': Boolean(error) || undefined,
'aria-errormessage': error ? 'customer-error' : undefined,
})}
/>
<Spinner showSpinner={showSpinner} />
</div>
<ul
{...cb.getMenuProps({
className: clsx(
'absolute z-10 bg-white shadow-lg rounded-b w-full border border-t-0 border-gray-500 max-h-[180px] overflow-scroll',
{ hidden: !displayMenu },
),
})}
>
{displayMenu
? customers.map((customer, index) => (
<li
className={clsx('cursor-pointer py-1 px-2', {
'bg-green-200': cb.highlightedIndex === index,
})}
key={customer.id}
{...cb.getItemProps({ item: customer, index })}
>
{customer.name} ({customer.email})
</li>
))
: null}
</ul>
</div>
)
}
function Spinner({ showSpinner }: { showSpinner: boolean }) {
return (
<div
className={`absolute right-0 top-[6px] transition-opacity ${
showSpinner ? 'opacity-100' : 'opacity-0'
}`}
>
<svg
className="-ml-1 mr-3 h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width="1em"
height="1em"
>
<circle
className="opacity-25"
cx={12}
cy={12}
r={10}
stroke="currentColor"
strokeWidth={4}
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)
}
백엔드와 클라이언트의 통합(integration) 부분을 🐨로 강조하였습니다.
기존 SPA / 백엔드 구조에 비해 코드가 얼마나 줄었는지 보세요!
결론
'FrontEnd' 카테고리의 다른 글
CSS Position Sticky 문제 해결하기 (0) | 2022.11.03 |
---|---|
리액트 쿼리 : 쿼리 취소하기 (0) | 2022.11.03 |
Critical Rendering Path 최적화하기 (0) | 2022.10.31 |
리액트와 유닛 테스트를 위한 클린 아키텍처 [부제, 의존성 주입은 정말 필요한가?] (0) | 2022.10.30 |
소프트웨어 엔지니어링 관점에서 바라본 CSS-in-js vs Tailwind CSS (0) | 2022.10.29 |