컴포넌트와 백엔드 코드를 함께 두는 이유(colocate)와 방법을 알아봅니다.
원문입니다 : https://www.epicweb.dev/full-stack-components
Full Stack Components
There’s this pattern I’ve been using in my apps that has been really helpful to me and I’d like to share it with you all.
www.epicweb.dev
백엔드와 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 |