본문 바로가기

FrontEnd

[Remix] 풀 스택 컴포넌트(Full Stack Components)

반응형

컴포넌트와 백엔드 코드를 함께 두는 이유(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>
  )
}
웹 애플리케이션의 타입 안정성(Fully Typed Web Apps)관점에서 이것이 얼마나 멋진지 설명하고
대신 이 사용자 경험을 위한 백엔드 및 프론트엔드 코드가 정확히 동일한 파일에 있다는 것이 얼마나 좋은지에 초점을 맞출 것입니다.
Remix는 컴파일러와 라우터를 이용하여 이를 달성합니다.
 
loader는 서버에서만 실행되며 데이터 가져오기를 담당합니다.
Remix는 로더에서 반환하는 것을 UI(서버와 클라이언트 양 쪽에서 실행됨)로 가져옵니다.
action은 서버에서만 실행되며 사용자의 양식 제출 처리를 담당하며
UI에서 사용하는 응답(예: 오류 메시지)을 반환하거나
새 페이지로 리디렉션할 수 있습니다.
그러나 백엔드 코드가 필요한 빌드가 전체 페이지인 것은 아닙니다.
사실, 우리가 만드는 많은 것들은 다른 페이지 내에서 사용되는 컴포넌트 입니다.
 
Twitter 좋아요 버튼이 이에 대한 좋은 예입니다.
 
트위터 좋아요 버튼은 여러 페이지에 여러 번 나타납니다.
일반적으로 이러한 재사용 가능한 컴포넌트 구축 치 다음과 같은 요소들을 고민합니다.
  • 클릭 이벤트 핸들러
  • 요청 가져오기
    • 오류
    • 낙관적 UI
    • 보류 상태
    • 레이스 컨디션
  • 상태 업데이트
  • 백엔드 코드를 포함하는 API 경로
    • 실제 데이터베이스의 데이터 업데이트를 처리합니다.

내 경험에 따르면 이 모든 작업에는 6개 이상의 파일이 포함되기도 하고
종종 2개의 리포지토리(때로는 여러 프로그래밍 언어도 포함됨)가 포함됩니다.
이 모든 것이 제대로 동작하도록 하려면 많은 작업이 필요합니다!

Remix의 풀 스택 컴포넌트를 사용하면 Remix의 리소스 경로(Resource Routes)기능 덕분에
단일 파일에서 이 모든 작업을 수행할 수 있습니다.
Full Stack Component를 함께 만들어 봅시다.
전체 코드는 여기(my own implementation)에서 보실 수 있습니다.
 
"invoice 생성" 경로에 사용되는 이 컴포넌트를 만들 것입니다.
 

리소스 경로 만들기

소스코드(start here)를 이용해 직접 실습해 보실 수 있습니다.

먼저 리소스 경로를 만들어 보겠습니다.
이 파일을 appp/routes 디렉토리의 아무 곳에나 넣을 수 있습니다.
저는 app/routes/resources 디렉토리 아래에 두는 것을 좋아하지만
의미 있는 자리라면 app/routes 아래 어디든 좋습니다.
 
이 컴포넌트를 app/routes/resources/customers.tsx에 넣을 것입니다.
리소스 경로를 만들려면 로더 또는 액션을 내보내기만 하면 됩니다.
이 컴포넌트는 데이터를 가져오기만 하고 실제로 아무 것도 제출할 필요가 없기 때문에 로더를 사용할 것입니다.
따라서 다음과 같은 매우 간단한 로더를 만들어 보겠습니다.
// app/routes/resources/customers.tsx
import { json } from '@remix-run/node'

export async function loader() {
  return json({ hello: 'world' })
}​
좋습니다. 개발 서버를 실행하고 /resources/customers 경로를 열어 보겠습니다.
{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),
  })
}
  1. 공개적으로 액세스할 수 있는 URL이지만 고객 데이터는 비공개여야 하므로 요청하는 사용자가 인증되었는지 확인하는 requireUser를 통해 보안을 유지해야 합니다.
  2. GET 요청이기 때문에 사용자 입력을 URL 쿼리 파라미터에서 가져옵니다(또한 쿼리가 예상대로 문자열인지 확인합니다).
  3. searchCustomers(query)를 사용하여 데이터베이스에서 일치하는 고객을 검색하고 JSON 응답으로 다시 보냅니다.
이제 쿼리 매개변수 /resources/customers?query=s로 해당 URL에 다시 요청해 봅시다.
{
  "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 컴포넌트 만들기

Remix(및 React Router)는 컴포넌트가 리소스 경로와 통신하는 데 사용할 수 있는 useFetcher이 있습니다.
이를 이용해 원하는 경로에 해당 컴포넌트 정의할 수 있습니다.
 
Remix 경로에서 추가 항목을 정의하고 export 할 수 있습니다.
경로 간에 import/export 하는 것을 권장하지는 않지만 이것은 하나의 매우 강력한 예외입니다!

이 포스트에서 논의하고 있는 것은 그다지 중요하지 않기 때문에 모든 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>
  )
}
🐨 Kody Koala는 앞으로 구현해야 할 부분을 보여줍니다.

백엔드 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)
좋습니다. 이제 로더를 호출하기만 하면 됩니다.
원래는 1문단에서 언급한 많은 것을 직접 처리해야 하지만,
Remix의 useFetcher가 자동으로 경쟁 조건 및 재제출을 처리하므로 매우 간단합니다.
사용자가 입력할 때마다 onInputValueChange 콜백이 호출되고 useFetcher를 트리거하여 쿼리를 제출할 수 있습니다.
 
(downshift 라이브러리 사용)
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' },
    )
  },
})
단지 customerFetcher.submit을 호출하고 사용자가 지정한 inputValue를 쿼리로 전달합니다.
액션은 현재 있는 파일을 참조하도록 설정되고 메서드는 'get'으로 설정되어 서버의 Remix가 이 요청을 로더로 라우팅합니다.
 
 
기능 구현은 끝났습니다. 하지만 사용자 경험을 위해 할 일이 남아있습니다.

보류 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} />
 
이제 showSpinner 값을 어떻게 결정할까요?
🐨는 displayMenu 선언 위에서 보류 상태를 여기에 위치하라고 지시합니다.
useFetcher를 사용하면 풀 스택 컴포넌트가 진행 중인 네트워크 요청의 현재 상태를 아는 데 필요한 모든 것이 제공됩니다.
useFetcher에는 'idle'| 'submit' | 'loading' 상태가 존재합니다.
우리의 경우 해당 상태가 idle 상태가 아니면 스피너를 표시해야 한다고 할 수 있습니다.
const showSpinner = customerFetcher.state !== 'idle'
훌륭하지만 깜빡이는 로딩 상태가 존재할 수 있습니다.

UI 그리기

이 부분은 특별하지 않습니다. 컴포넌트를 가져와서 렌더링하기만 하면 됩니다.
app/routes/__app/sales/invoices/new.tsx를 열고 NewInvoice 컴포넌트까지 아래로 스크롤하면
<CustomerCombobox error={actionData?.errors.customerId} />  옆에서 🐨 가 기다리고 있습니다.
또한 최상단에 가져오기를 추가해야 합니다.
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),
  })
}
타임아웃 시간 30은 하면 꽤 빠르지만 반응이 즉각적인 네트워크 에뮬레이션에 충분합니다.
아마도 대부분의 사람들이 실제 상황에서 경험할 수 있는 가장 빠른 경험에 매우 가까울 것입니다.
로딩 경험은 다음과 같습니다.
입력하는 모든 문자와 함께 로딩 스피너가 깜박입니다. 좋지 않습니다.
저는 실제로 kentcdodds.com의 글로벌 로딩 스피너 때문에, 이에 대해 고민하고 있었습니다.
사이트를 도와준 Stephan Meijer가 바로 이 문제를 해결하기 위해 npm.im/spin-delay를 구축했습니다!
이를 showSpinner 계산에 추가해 보겠습니다.
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 / 백엔드 구조에 비해 코드가 얼마나 줄었는지 보세요!


결론

Remix를 사용하면 전체 페이지 경로뿐만 아니라 개별 컴포넌트에 대한 UI 및 백엔드 코드를 함께 배치할 수 있습니다.
모든 복잡성을 한 곳에 위치하는 것은, 복잡한 컴포넌트를 만드는 데 필요한 간접 참조의 양을 크게 줄일 수 있다는 것을 의미하기 때문에
저는 이러한 방식으로 컴포넌트를 사용하는 것을 좋아합니다.
 
위 코드에 "디바운스"가 없다는 것을 눈치채셨을 것입니다.
대부분의 경우 이와 같은 컴포넌트를 만들 때 사용자가 입력하는 즉시 요청을 보내지 않도록 디바운스를 추가해야 했습니다.
이것은 서버의 부하를 줄이는 데 유용하지만,
사실 주로 사용하는 이유는 경쟁 조건을 제대로 처리하지 못하고, 때때로 응답 순서가 맞지 않기 때문입니다.
Remix의 useFetcher를 사용하면 더 이상 걱정할 필요가 없으며 너무 좋습니다!
 
오늘 튜토리얼에서 이것을 실제로 볼 기회는 없었지만
컴포넌트가 돌연변이를 수행하는 경우(예: 채팅 메시지를 읽은 상태로 표시)
Remix는 페이지의 데이터를 자동으로 재검증하여 벨 아이콘이 표시되도록 합니다.
우리가 그것에 대해 생각할 필요 없이 많은 알림이 알아서 업데이트될 것입니다.
Flux가 처음 발표되던 때를 기억하시나요? (when Flux was introduced)
 
P.S 보류 상태를 낙관적 업데이트로 개선하는 방법은 여기(Bringing Back Progressive Enhancement)를 참조하세요.

 

반응형