Full Stack Components

Kent C. Dodds
AuthorKent C. Dodds

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. I’m calling it “full stack components” and it allows me to colocate the code for my UI components with the backend code they need to function. In the same file.

Let’s first start with the first time I’ve ever seen such a strong marriage between the UI and backend. Here’s what it’s like to create a web page in 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>
)
}

I’m going to just gloss over how awesome this is from a Fully Typed Web Apps perspective and instead focus on how nice it is that the backend and frontend code for this user experience is in the exact same file. Remix enables this thanks to its compiler and router.

The loader runs on the server (only) and is responsible for getting our data. Remix gets what we return from that into our UI (which runs on both the server and the client). The action runs on the server (only) and is responsible for handling the user’s form submission and can either return a response our UI uses (for example, error messages), or a redirect to a new page.

But, not everything we build that requires backend code is a whole page. In fact, a lot of what we build are components that are used within other pages. The Twitter like button is a good example of this:

Twitter Like button getting clicked

That twitter like button appears many times on many pages. Normally the way we’ve built components like that is through a code path that involves click event handlers, fetch requests (hopefully thinking about errors, optimistic UI/pending states, race conditions, etc.), state updates, and then also the backend portion which would be an “API route” to handle actually updating the data in a database.

In my experience, doing all of that involved sometimes a half dozen files or more and often two repos (and sometimes multiple programming languages). It’s a lot of work to get that all working together properly!

With Full Stack Components in Remix, you can do all of that in a single file thanks to Remix’s Resource Routes feature. Let’s build a Full Stack Component together. For this example, I’m going to use a feature I built for my own implementation of the “Fakebooks” example app demonstrated on Remix’s homepage. We’re going to build this component which is used on the “create invoice” route:

New invoice page showing customer combobox listing options as a query is typed

Create the Resource Route

Note: If you’d like to follow along, start here.

First, let’s make the resource route. You can put this file anywhere in the app/routes directory. For me, I like to put it under app/routes/resources directory, but feel free to stick it wherever makes sense under app/routes. I’m going to put this component in app/routes/resources/customers.tsx. To make a resource route, all you need to do is export a loader or an action. We’re going to use a loader because this component just GETs data and doesn’t actually need to submit anything. So, let’s make a super simple loader to start with:


// app/routes/resources/customers.tsx
import { json } from '@remix-run/node'
export async function loader() {
return json({ hello: 'world' })
}

Great, with that, let’s run the dev server and open up the route /resources/customers:


{hello:"world"}

Great! We now have an “endpoint” we can call to make requests. Let’s fill that out to actually search customers:


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),
})
}

Here’s what that’s doing:

  1. This is a publicly accessible URL but our customer data should be private, so we need to secure it via requireUser which will check that the user making the request is authenticated.
  2. Because this is a GET request, the user input will be provided via the URL search params on the request, so we grab that (and also validate that the query is a string as expected).
  3. We retrieve matching customers from the database with searchCustomers(query) and send that back in our JSON response.

Now if we hit that URL again with a query param /resources/customers?query=s:


{
"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"
}
]
}

Sweet! Now all we need is a component that we can use to hit this endpoint.

Creating the UI Component

In Remix (and React Router), you get a useFetcher hook that our component can use to communicate with resource route and we can define that component anywhere we like. I had a real “aha 💡” moment when I realized that there’s nothing stopping you from defining and even exporting extra stuff from Remix routes. I don’t recommend making a big habit out of importing/exporting things between routes, but this is one very powerful exception!

I’m going to just give you all the React/JSX stuff because it’s really not that important for what we’re discussing in this post. So here’s the start to the UI component:


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>
)
}

We’ve got 🐨 Kody the Koala in there showing the specific touch-points that are of particular interest to what we’re building. A few things to note:

  1. We’re using downshift to build our combobox (a great set of hooks and components for building accessible UIs like this that I built when I was at PayPal).
  2. We’re using tailwind to style stuff in here because it’s amazing and it also helps give us that “Full” part of “Full Stack Component” by including the styling within this component as well.

Hookup the UI to the backend

Let’s follow Kody’s instructions and add useFetcher in here to make a GET request to our resource route. First, let’s create the useFetcher and use its data for the list of customers:


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)

Great, so now, all we have to do is call the loader. As I said, normally this involves a lot of work, but with Remix’s useFetcher handling race conditions and resubmissions for us automatically, it’s actually pretty simple. Whenever the user types, our onInputValueChange callback will get called and we can trigger useFetcher to submit their query there:


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' },
)
},
})

We simply call customerFetcher.submit and pass along the user’s specified inputValue as our query . The action is set to reference the file that we’re currently in and the method is set to 'get' so Remix on the server will route this request to the loader.

As far as “functional” goes, we’re finished! But we do still have a bit of work to do to make the experience great.

Add pending UI

For some of our users, this will be enough, but depending on network conditions over which we have no control, it’s possible this request could take some time. So let’s add pending UI.

In the JSX next to the <input />, we’ve got 🐨 telling us we should render a spinner. Here’s a spinner component you can use


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>
)
}

So let’s render that next to the input:


<Spinner showSpinner={showSpinner} />

And now, how are we going to determine the showSpinner value? 🐨 is sitter there next to the displayMenu declaration inviting us to determine that value there. With useFetcher, we’re given everything we need to know the current state of the network requests going on for our Full Stack Component. useFetcher has a .state property which can be 'idle' | 'submitting' | 'loading'. In our case, we’re safe to say that if that state is not idle then we should show the spinner:


const showSpinner = customerFetcher.state !== 'idle'

That’s great, but it can lead to a flash of loading state. We’ll leave it like that for now though so you can play around with it.

Render the UI

This part is nothing special. We simply import the component and render it. If you open up app/routes/__app/sales/invoices/new.tsx and scroll down to the NewInvoice component, you’ll find 🐨 waiting there with <CustomerCombobox error={actionData?.errors.customerId} /> ready for you. You’ll also need to add an import at the top: import { CustomerCombobox } from '~/routes/resources/customers'

Note, the error prop and things are outside the scope of this post, but feel free to poke around if you’d like.

Improve the pending state

With the UI rendered, the whole thing is now functional! But we do have one small issue I want to address first. In the loader, I want you to add a bit of code to slowdown the request to simulate a slower network:


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),
})
}

With 30 as our timeout time, that slows it down just enough to be a pretty fast network, but not instant. Probably pretty close to the fastest experience most people would have in a real world situation. Here’s what our loading experience looks like with that:

Shows the customer combobox with a spinner that flashes for a few milliseconds

Notice that our loading spinner flashes with every character I type. That’s not great. I actually was bothered about this with my global loading indicator for kentcdodds.com, and Stephan Meijer who was helping me with the site built npm.im/spin-delay to solve this very issue! So, let’s add that to our showSpinner calculation:


const busy = customerFetcher.state !== 'idle'
const showSpinner = useSpinDelay(busy, {
delay: 150,
minDuration: 500,
})

What this is doing is it says: “Here’s a boolean, whenever it changes to true, I want you to continue to give me back false unless it’s been true for 150ms. If you ever do return true, then keep it true for at least 500ms, even if what I gave you changes to false.” I know it sounds a bit confusing at first, but read that through a few more times and you’ll get it I promise. Feel free to play around with those numbers a bit if you like.

In any case, doing this solves our issue!

Customer combobox showing a list of customers and no spinner shown

On top of this, if we increase that timeout delay in our loader to 250ms, then we get an experience like this:

Customer combobox with customers showing as a query is typed and a spinner being displayed

And I think that looks just great!

Final version

Here’s the finished version of our Full Stack Component:


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>
)
}

I love how the integration points are pretty minimal here, so I’ve highlighted those lines above.

Conclusion

Remix allows me to colocate my UI and backend code for more than just full page routes, but also individual components. I’ve been loving working with components this way because it means that I can keep all the complexity in one place and drastically reduce the amount of indirection involved in creating complex components like this.

You may have noticed the lack of "debounce" on this. Most of the time I've built components like this, I've had to add a debounce so I don't send a request as soon as the user types. Sometimes this is useful to reduce load on the server, but if I'm being honest, the primary reason I did it was because I wasn't handling race conditions properly and sometimes responses would return out of order and my results were busted. With Remix's useFetcher, I don't have to worry about that anymore and it's just so nice!

We didn’t get a chance to see this in action in our tutorial today, but if the component were to perform a mutation (like marking a chat message as read) then Remix would automatically revalidate the data on the page so the bell icon showing a number of notifications would update without us having to think about it (shout-out to folks who remember when Flux was introduced 😅).

Cheers!

P.S. In the tweet situation we started with, we could take the pending state even further to optimistic UI which is also awesome. If you want to learn more about how to do that, then checkout my talk at RenderATL (2022): Bringing Back Progressive Enhancement.

Share this article with your friends

Kent C. Dodds
Written by Kent C. Dodds

A world renowned speaker, teacher, open source contributor, created epicweb.dev, epicreact.dev, testingjavascript.com. instructs on egghead.io, frontend masters, google developer expert.

Learn more at Epic Web Conference

The Full Stack Web Development Conference of Epic proportions


April 11th, 2024
9am—5pm
Park City, UT
Prospector Square Theatre
Tickets on sale!

Follow EpicWeb.dev

Get the latest tutorials, articles, and announcements delivered to your inbox.

I respect your privacy. Unsubscribe at any time.