I respect your privacy. Unsubscribe at any time.
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 }: LoaderFunctionArgs) { const projects = await getProjects() return json({ projects })}export async function action({ request }: ActionFunctionArgs) { 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:
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:
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 GET
s data and doesn’t actually need to submit anything. So, let’s make a super simple loader to start with:
// app/routes/resources/customers.tsximport { 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 { LoaderFunctionArgs } 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 }: LoaderFunctionArgs) { 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:
- 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. - 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). - 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 { LoaderFunctionArgs } 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 }: LoaderFunctionArgs) { 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:
- 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).
- 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 sitting 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 }: LoaderFunctionArgs) { 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:
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!
On top of this, if we increase that timeout delay in our loader
to 250
ms, then we get an experience like this:
And I think that looks just great!
Final version
Here’s the finished version of our Full Stack Component:
import type { LoaderFunctionArgs } 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 }: LoaderFunctionArgs) { 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.