Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save thevinayysharma/a064cb3d9b2af38e6251f2b8cc6b98f3 to your computer and use it in GitHub Desktop.

Select an option

Save thevinayysharma/a064cb3d9b2af38e6251f2b8cc6b98f3 to your computer and use it in GitHub Desktop.

Everything I Know About UI Routing

Definitions

  1. Location - The location of the application. Usually just a URL, but the location can contain multiple pieces of information that can be used by an app
    1. pathname - The "file/directory" portion of the URL, like invoices/123
    2. search - The stuff after ? in a URL like /assignments?showGrades=1.
    3. query - A parsed version of search, usually an object but not a standard browser feature.
    4. hash - The # portion of the URL. This is not available to servers in request.url so its client only. By default it means which part of the page the user should be scrolled to, but developers use it for various things.
    5. state - Object associated with a location. Think of it like a hidden URL query. It's state you want to keep with a specific location, but you don't want it to be visible in the URL.
  2. Path - A pattern used to match locations against to perform some routing. Something like "/users" or "/invoices/:invoiceId"
    1. Dynamic Segment - A dynamic part of the path that will be parsed from the URL and provided to the app, like :invoiceId in "/invoices/:invoiceId".
    2. Wild Card - The rest of a URL at the end like "/users/*", so any url that matches the first portion will match no matter how many segments follow.
    3. Regex - Ability to URLs with regular expressions.
  3. Matching
    1. URI
    2. Parameters
    3. State
    4. Query
  4. History
    1. Listener
    2. State
    3. History Stack (push/replace/index)
  5. Routes
    1. Path
    2. Data
    3. Validation
  6. Links
    1. href
    2. state
    3. active status
  7. File System API

Definitions

  1. Location -

tl;dr On Client Side Routing

When a user visits a webpage, the location is the the first (and probably the only) data the app has to generate a UI. The location is matched against a set of routes, a route is selected, (maybe) data is loaded, and finally the UI is rendered.

After the initial render on the client, a history listener is set up. When the history's location changes--either through the user clicking links or the programmer redirecting with code--the new location is matched against the routes, (maybe) data is fetched, and the app is updated to the new page.

Some implementations will save the data loading of a route for after the new page is rendered (and display some spinners). There are a handful of tradeoffs regarding data loading on page transitions, so we'll save that conversation for later.

History of Client Side Routing

In the early days of the web we just used files for our routes. Drop an HTML file or a PHP file on the server and away you went. A common server was Apache, so occasionally you'd do some configuration there to map URLs to a file that lived somewhere else on your server.

After this, frameworks like Ruby on Rails became popular. Instead of a bunch of files living in the public directory of your website, a single program served all URLs, and the "route configuration" was born. You would declare all the URLs your app could respond to in one place.

Then we started wanting to update the URL without going back to the server for a full page reload and client side routers were born.

[maybe delete this, not sure how useful it is and runnig out of ideas on where I was going with it]

Dynamic vs. Static Routing

There are two types of routing: dynamic and static.

Static Routing

If you’ve used Rails, Express, Ember, Angular, or React Router pre v4, you’ve used static routing. In these frameworks, you declare your routes as part of your app’s initialization before any rendering takes place. React Router pre-v4 was also static (mostly). Let’s take a look at how to configure routes in express:

// Express Style routing:
app.get('/', handleIndex);
app.get('/invoices', handleInvoices);
app.get('/invoices/:id', handleInvoice);
app.get('/invoices/:id/edit', handleInvoiceEdit);

app.listen();

Dynamic Routing

Dynamic Routing is when the routing happens as part of your app's render lifecycle. So instead of having a route configuration somewhere, you'll have some run-time version of it. In the case of React Router v4-5, you have a <Route> component that can be rendered anywhere and knows how to match the URL and renders almost like an if block.

Here's what it looks like in React Router:

const App = () => {
  return (
    <div className="layout">
      <GlobalNav />
      <Route
        path="/matched/while/rendering"
        render={() => <div>This only renders when matched</div>}
      />
    </div>
  );
};

Tradeoffs

Dynamic Routing is great using application data for conditional route matching:

For example, code-splitting just naturally happens, since there is no route config at the top there is no need for a "code-splitting feature" in a dynamic router.

Another example is authentication. There's no need for a mechanism to "protect routes": you simply don't render those routes until the user is authenticated, if they're not authenticated, you just render a login screen instead (at every url, but you just don't have routes yet).

Additionally, if a small screen renders a significantly different UI than a large screen, you simply render different routes given the screen size data that you know at render time.

In general, the ability to add or remove routes as part of your normal render tree opens up a lot of potential features. Unfortunately most web developers and designers haven't really explored what we could be doing. We're all still very used to thinking about routing simply as "pages", the same way we've thought about them since the beginning of the web.

Static Routing is great for data loading:

If your routes live outside the render lifecycle, you're able to work out data requirements before rendering the next page, or even preload data before a link is even clicked. In general it gives you more control over transitions in the client and more comprehensive server pre-rendering. We'll talk more about this in the data loading section.

Static routes are also easier to see all of the URLs your app responds to at a glance, where dynamic routing is often spread across the app. However, with static routes you're likely to have git conflicts with other teams, increase the base bundle size, etc. where dynamic routes don't have these problems. Tradeoffs everywhere.

URL-As-Data vs. URL As-Side-Effect

Most routing developers are familiar with uses "URL as data". Meaning, we use the URL as data to match against to render the UI and navigation simply changes that URL to start over again from the top.

"URL as side effect" is another approach that treats the URL as nothing more than a side-effect, or reflection, of the app state.

A good analogy is the document title. When your app gets into a state where you want to change the document title, you go ahead and do it. URLAASE (lol) is the same. When your app reaches a state you think is worth a new URL, you push it up there.

Some complexity enters with this approach when you boot the app initially. Suddenly the URL is no longer a side-effect but the initial state of the app. This causes two code paths for each URL the app supports, opening up the risk of missing a case in one place but not the other. If you've ever done mobile development without a "URL as data" router, you know how difficult it can be to support every feature as a "deep link" into the app.

Routers don't need to pick one approach over the other. React Router is a "URL as data router" but it also supports treating the URL as a rendering target with the <Redirect> component. An example of using both approaches in the same app is a page that changes the URL as the scroll position changes. As the user scrolls, you can redirect to a new path that represents that position as a side-effect, but you'll also need to write the code to scroll down on both the initial load and user navigation.

Pages vs. Layouts

Page Based Routing

An simple and effective way to think of routing is just matching the url to a path and then calling a function that returns the page. I call this a page-based router.

While page-based routers shine in their simplicity, you have to repeat a lot of the UI across all of your pages, coupling them all together: like the global nav and footer. As you get more contextual on the page, you tend to repeat even more code across the contextual pages. Consider the urls /, /invoices, and /invoices/123.

The route config and views might look something like this:

router.map({
  '/': () => <Root />,
  '/invoices': () => <Invoices />,
  '/invoices/:id': params => <Invoice id={params.id} />
});

const Root = () => (
  <div className="root">
    <GlobalNav />
    <Dashboard />
    <Footer />
  </div>
);

const Invoices = () => (
  <div className="root">
    <GlobalNav />
    <div className="invoices">
      <InvoicesNav />
      <InvoiceStats />
    </div>
    <Footer />
  </div>
);

const Invoice = ({ id }) => (
  <div className="root">
    <GlobalNav />
    <div className="invoices">
      <InvoicesNav />
      <div className="invoice">
        <p>the actual invoice view</p>
      </div>
    </div>
    <Footer />
  </div>
);

Note all the repeated layout code. There are ways to use the component model of your UI framework to help clean this up. One of those approaches is to bake it into your router.

Layout Based Routing

After many years of web development we've learned that usually nested URLs map to nested UI. Look at the classNames and the URLs in our example: / ("root") => /invoices ("invoices") => /invoice/:id ("invoice").

The Ember.js router embraced and popularized this idea; we copied it in React Router, and many other routers have copied it as well. I like to call these "layout-based" routers.

With a layout-based router you are able to develop these routes in isolation without repeating all the layout code. Each page is now just the code that matters for that URL, and it gets rendered inside the parent route--or rather, the parent layout.

Continuing with our invoices example, in a layout based router like @reach/router it looks something like this:

<Router>
  <Root path="/">
    <Dashboard path="." />
    <Invoices path="invoices">
      <InvoicesStats path="." />
      <Invoice path=":id" />
    </Invoices>
  </Root>
</Router>;

const Root = ({ children }) => (
  <div className="layout">
    <GlobalNav />
    {children}
    <Footer />
  </div>
);

const Invoices = ({ children }) => (
  <div className="invoices">
    <InvoicesNav />
    {children}
  </div>
);

You can see the repetition is gone, each level of the route hierarchy is also nested UI.

Web designers and CSS developers tend to think in layouts because it helps maintainability in the long run. Maybe /invoices/123 is no longer nested inside of /invoices, or <Root/> becomes a child of a bigger application. By isolating each layout to be unaware of its surrounding markup, its much easier to make adjustments to the entire site's design because you're able to just move them around where they need to go. Additionally, adding new features to deep layouts runs no risk of forgetting the global footer, or the contextual navigation.

Note that layout based routers are perfectly capable of working like page-based routers.

Server Side Approaches

Before we can talk much about anything else, we should address the three server side approaches used to deliver the application to the browser.

  1. "Single Page App"
  2. Serverside Pre-Rendering
  3. Static File Generation

Server Side Approach: "Single Page App"

A traditional website handles URLs on the server. This can be as simple as an html file for each page of your site: /index.html, /contact.html etc. But in order for a client side router to work, every URL the server receives needs to run the same application. Or, in other words, every URL needs to serve the same html file.

This is why they've been called "Single Page Applications". There's really only a single index.html that handles every URL in the app, and then a router decides what UI to render with JavaScript in the browser.

The body of the html file is typically blank, with a single element for JavaScript (and your client side router) to mount an application into.

<html lang="en">
  <meta charset="utf-8" />
  <title>Bare Bones SPA</title>
  <body>
    <div id="root"></div>
    <script src="the/app.js"></script>
  </body>
</html>

No matter what the URL is, when if this file is served, then the router inside of app.js can read the URL and determine what page to display.

Here's a bare-bones express server to do just that:

const express = require('express');
const app = express();

// serve all static files that exist as the file (CSS, JS, etc.)
app.use(express.static('public'));

// serve every other request to your root index html file
app.get('*', res => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(5000);

Now if the user visits /invoices/123 they will get the index.html file and the client side router can decide what to render.

This is how create-react-app plus a router like React Router works.

Server Side Approach: Server Pre-Rendering

Before rendering views in the browser became popular, web developers built their UI server side and then made it interactive client side with JavaScript. Oftentimes they'd end up writing the same views in both places, once for the server render and once for the dynamic updates. They'd always get out of sync.

Thanks to Node.js, we're now able to use the same UI code in the client and the server.

The technique is pretty straight-forward on paper, but gets a bit complex when you start bringing in data loading and code splitting concerns, so we'll ignore those for this section.

Instead of sending down an empty <div> like in the "Single Page App" section, server pre-rendering actually renders the UI on the server and sends down an HTML file the very same as normal server rendered application from several years ago. In fact, you could use popular libraries like React as part of a server rendering framework without sending JavaScript to the browser at all.

For this to work, we need to send the right UI down for the right URL. Suddenly what we think of as a "client side router" is really a server side router as well.

Here's a modified version of the express server we had earlier using a pretend "UI renderer" (like React) and a pretend router (like React Router).

Server Side Approach: Static File Generation

In the early days of web development, before client side routing and even before server side routing, we just had html files. We just put html files into the public facing directory of our web servers and we had a "route".

A silly homepage public directory could look like this:

├── about.htm
├── contact.htm
├── index.htm
└── pics
    ├── IMG_2342.JPEG
    ├── IMG_3532.JPEG
    ├── cat.htm
    ├── dog.htm
    ├── index.htm
    ├── me.htm
    └── me.jpeg

This is why URLs look like a directory structure, because that's what they originally were!

Modern tools have continued to take this approach, but instead of writing separate html files for every page, you can create just the body of the page with JavaScript like its a Single Page app. At build time, the pages are compiled into full HTML files at the same paths where they were authored for the web server.

When a user visits, and an HTML file is loaded into the browser, the JavaScript app, along with a router, is loaded. Every navigation from now on acts exactly like a Single Page Application--no more round trips to the server for assets, just data.

It feels like the old days of just creating files to get a new "route" with all performance benefits of static file servers, except the transition from "plain html" to dynamic, interactive UI is seamless because it's the same code.

Gatsby.js works this way, and Next.js supports it (in addition to full server rendering).

Code Splitting and Bundle Size Concerns

Server Rendering

Data Loading

Something something about static is easier for data loading but I don't like coupling data to routes because there are so many interactions/component transitions that are data dependent (combobox, tabs, wizards, checkout workflows) but not necessarily routes. HOWEVER, maybe they should be "routes", even if just using location state.

Something something about my hopes for suspense to bend all the tradeoffs here but I'm still not sure it will be the silver bullet I thought it was 18 months ago.

Clientside Page Transitions

Accessibility: Scroll Restoration

Accessibility: Focus Management

Animation

Dynamic Routes (Screen Size, Authentication, etc.)

Navigation Based Matching (instagram)

Relative Routes, Links

Navigation Blocking

Queries

History Stack

Mobile

File System API

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment