cra
mr

wtb: Progressive SPAs

I’ve been using React a long time at this point, and it’s by far my favorite approach to building a UI - interactive or not. Most of that time has been spent at Sentry, but whenever I start a new project, I still reach for React. I want to talk a little about what I’d love to see the React ecosystem adopt, and where I see some of the gaps with approaches today, particularly around the slow evolution of Single Page Applications.

First, some context

My primary use of React has been with a Single Page Application. That is, I want to pay the cost of loading the JavaScript and associated components once, or close to once. The decision on one or one plus comes down to the type of app I’m building (do I want it to work offline?), but otherwise the behavior is the same. I like this model because it’s the closest you’re going to get to it feeling like an application. Take note, as that word is important here.

A classic Single Page Application

I’m not often building content-focused websites. I’m building applications that I want to be interactive in the same way I want Slack, Discord, or Twitter to be interactive. I like the concept of thinking of these two different paradigms in clean terms: a website vs an application. One has a focus on native-feeling interactivity, the other is the equivalent to your blog. Obviously those lines blur, but it’s a good way to frame the subject.

So when I’m building these applications, I usually throw in something like Vite these days and I’m off to the races. It’s removed all of the bundler problems of the past and made it dumb simple to actually ship the thing I want. The problem comes into play as soon as we talk about server rendering (or hydration in common contexts).

Why do I want hydration?

I have a silly little side project called Peated. It’s arguably just a way for me to scratch my “build something” itch, but it’s also been very useful to both keep myself grounded, as well as to help the Sentry team dogfood a common paradigm in what we consider to be the most important ecosystem in software (JavaScript). Peated at a high level is two things: a way to keep a log of whisky you’ve tried, and a future database of everything whisky.

That latter example - a database of whisky - that’s where server rendering comes into play. You see, I want maximum compatibility and speed for a content-focused website. To achieve the whisky log I want interactivity. It creates a crossroads where there’s not a great technology set that solves this for me.

Now before you go saying “use Next.js” or “HTMX!!”, I want to be clear what I mean by solves. I want something that gets out of my way, doesn’t require complex infrastructure, and minimizes the knowledge overhead to maintain and build it. Technologies like Next provide a ton of great behavior, and they might ease some of my challenges, but I dont think they’re quite aiming to solve what I’m doing. That’s totally fine! I’m actually using Remix, which is somewhat similar, but even with that I still run into limitations.

What I want at the end of the day is to implement a SPA and have it also be able to initially hydrate on the server.

Its worth noting that this is not entirely connected to search engine indexing, but it is part of it. While Google may commonly index some JavaScript content, that’s an awfully silly price to pay in inefficiency for other crawlers.

Frontend as a Service

I’ve been throwing around this concept of thinking of your frontend as a service. Not in the SaaS sense, but in the architectural stack. You have a backend (e.g. Rails, Django), but you want to adopt a modern rendering engine for the UI. Probably because you have interactivity, which if we’re honest, almost everyone does. That means you want something like React.

That doesn’t mean you need to implement your RPC layer in Node.js, and that’s where the industry keeps breaking down.

What I want to be able to do is take my favorite full-stack framework of choice, axe the template engine, and adopt React entirely. I want it to have minimal compromise, and I want it exclusively focused on behaving like a modern-era template renderer.

This is where you can easily represent this as a SPA, and is exactly how many of us in the enterprise space are already building software (for a decade now, mind you). We have a historical or preferred stack, we expose it as a REST API, and then we layer React on top. There’s a progression we can take that gets us to what I believe is the holy grail, which I’m going to just call a progressive SPA.

A progressive Single Page Application

Kent has also written about something similar (possibly the same) calling it a Progressively Enhanced SPA (PESPA), but I wanted to avoid the distraction of acronyms.

There is no server

First and foremost, the rendering engine needs to pretend it’s always operating on the client. That means if you have a server-secured database, you’re not talking to it from this UI application. You’re continuing to use another stack - another publicly-exposed service to talk to it. That means you’re maintaining client-side-like authentication for comms.

In practice that’s actually pretty easy. It just requires you to shim the network utilities (e.g. via dependency injection) and API abstractions.

Let’s use Remix as an example, starting with the way you’d do this today:

export async function loader({ context: { bearerToken }}) {
  return json({
      postList: await getPostsFromMyApiServer(bearerToken);
  });
}

In Remix this is equivalent to rendering context for a route. It hits the server, runs this loader code, and then serializes the outcome to render the given route’s component. That means a server-hop for every data load, not what we want.

You can do one better by adding an additional clientLoader annotation, which replaces loader when doing client-side navigation:

export async function clientLoader() {
  const bearerToken = getTokenFromCookies();
  return json({
    postList: await getPostsFromMyApiServer(bearerToken);
  });
};

You’ll notice, the code is almost the same! The gap here is we have no way to inject context in the client loader. You can actually work around this today, which I’ve done on Peated, by having an abstraction which injects compatible interfaces as context and exports two functions:

export const { loader, clientLoader } = makeIsomorphicLoader(
  async ({ context: { trpc }}) => {
    return { postList: await trpc.postList.query() };
  }
);

In this case I’m actually leveraging the great work by the tRPC authors who provide compatible interfaces for both the server and the client, and I’m exposing those interfaces via this abstraction. You can see the actual implementation on GitHub.

What’s important here is that all of the context is fully interface-compatible, and shimmed out both on the client and the server with the appropriate bindings. The simplest way to reason about this is you have to implement fetch on both runtimes. It’s something that’s already commonly done today, so I consider this a solved problem.

Client components are mandatory

One of the most nuanced parts of building this compatible application is often splitting out code that can only run on the client. A good example of this would be a Google Maps implementation. Many of the controls are only interactive or at the very least require deferred loading. This is probably the biggest paradigm shift of the whole thing today, and people have taken a variety of approaches to solve it.

The approaches I feel like resonate the most here are actually two different concerns:

  1. React’s Suspense, or a similar model, where you explicitly tell it something is deferred. That border could then also implicitly mean it can only run on the client.

  2. Naming conventions for overrides are extremely easy on the developer. For example client.component.tsx as an override for component.tsx.

I’ve not been able to quite wrap my head around the implementation of Suspense as it seems to try to resolve a few problems, but the core concept feels like the right fit to me. In most cases things are as simple as this:


function PostList() {
    const {postList, loading} = usePostList();

    if (loading) return null;

    return <ul>{ postList.map((p) => <li>{p.title}</li> }</ul>
}

The problem I see with Suspense is the complexity of the abstraction. To work with the above, you end up with a bunch of boilerplate:

function ExportedPostList() {
    return <Suspense fallback={<Loader>}><PostList /></Suspense>;
}

I don’t have much value to add to the conversation, I just feel the current APIs end up a bit clunky in many places, with a lot of duplicated code. I think the main takeaway I’ve had from dogfooding is developers need control, which usually the implementations have provided. The two most common scenarios I’ve seen that could be optimized around, both which signal things that aren’t important enough to hydrate at runtime:

  1. The data is not important enough to have at runtime (and should be done async for performance)
  2. The interactivity is only for clients

I’d like to believe there’s a more straightforward API we could take for both of these, and maybe they’re not the same. They do however share roughly the same goals. Specifically we want a skeleton when its deferred, which might be some placeholder data, a spinner, or nothing at all.

I do think there might be some ergonomics that can be improved if the abstractions were separate, such as component.client.tsx (or <ClientOnly>) vs <Suspense>.

Frameworks are the solution

Due to the context abstraction (DI) required, I believe it needs to be implemented at a framework level. However, that framework, in React’s case, could actually just be a tiny shim on top of React Router. If that sounds like Remix that’s for good reason, and it’s actually why I’m using them in this example. The problem with Remix today is it’s still trying to be a meta framework with loose opinions. That’s probably ok, but what I’d like to see is a framework author take an opinionated approach on this, stop giving choice, and move the needle forward.

I want to be able to take an application - something say using react-router (or an alternative/future) - and then upgrade that application to run on the server. That upgrade path likely looks fairly minimal:

  1. Components need upgraded to a deferred model (ala Suspense above)
  2. Context isomorphism needs implemented (commonly for things like auth and API calls)
  3. Client-only components need addressed case-by-case (whereas today its a non-concern)

That doesn’t mean it’d be easy for a big application like Sentry, but there’s no world that will ever exist where we’d throw away our API service in favor of some node-coupled API. I’m after a world where we can progressively upgrade to improve the performance of the application, making it more efficient for indexers, more efficient for CDNs, and generally speaking, more efficient overall.

How Remix could get here

I’m heavily focused on Remix in this example, so I want to talk about a path I see as making this possible. I shared an example piece of code I’m using on Peated, but that alone isn’t enough. I actually have a proof of concept to migrate to @tanstack/query, but there’s still some challenges.

I would make isomorphic loading the default behavior. I believe this is the safest path forward for what traditional web developers are trying to accomplish, and giving people too many choices just creates complexity and friction for adoption. I think its ok that this default behavior requires you to learn how technology works, and we should stop over rotating around flexibility and misguided complexity. Learning the concept I’m talking about with dependency injection is not complicated, and if it were the primary directive of a framework you’d be able to easily educate and minimize the cognitive load to resolve it.

I would remove the concept of querying datastores directly. It’s ok that it’s possible, but most educational material around these kinds of frameworks focuses on them for these simplistic uses. That’s ok, but many companies are not building simple TODO list applications. We need technology that solves these problems more than we need yet-another server template engine. This needs combined with the above DI approach to educate folks on how to hit the external APIs correctly.

I would entirely optimize the framework around removing server hits. Latency is the right goal, but bandwidth consumption is not. We expect to be able to download applications, and what we truly want is something akin to a PWA. There’s obviously nuance here - the application should be able to at least somewhat degrade - but what we don’t need is yet-another-framework for building a CMS or blog. I want to fully optimize the interactivity of my application, or I don’t want to build an application in the first place.

I would not implement my own data abstraction. Remix tries to do this right now, and while the goals are great, the reality is I want to use something like tRPC or @tanstack/query. Both of those implementations given me a really solid way to abstract caching (which is mandatory for isomorphism to work). I’ve already made the mistake of moving off of Query to remove boilerplate in Remix, and now I’m building new abstractions go move back onto it.

In Closing

I’m going to continue to try to achieve this goal (within Remix and without), but what I’d really love to see is someone put a stake in the ground and truly try to solve this. I’ve only explored the React ecosystem, as it’s what I’m familiar with, so it’s possible there’s approaches like this succeeding in other spaces.

That said, if you are building this on top of React (psst. maybe TanStack Router?) I’d love to hear about where you’re going and see if I can fit it into my use cases.

If nothing else, I’d just ask folks to consider that SPAs are a phenomenal paradigm with minimal tradeoffs, and they’re for building applications, which not every website should be. I’d love to see us bridge the gap a little more between those two concepts, and I’m just suggesting one approach above that is extremely attractive to me.

More Reading

Lunch Isn't Free

What is Brand?

Fire Faster, but Hire Better

Open Source is not a Business Model

CTA: Structuring Unstructured Data