DEV Community

Cover image for Remix-ing Routing in Angular 💿
Brandon Roberts
Brandon Roberts

Posted on

Remix-ing Routing in Angular 💿

The team at Remix are building a web framework that is built on the priniciples of React Router and uses React for rendering. They also have bigger plans to bring that same routing functionality to other frameworks. Recently, the team put out a blog post that they are Remixing React Router by taking the underlying pieces of React Router and making them framework agnostic for any framework to use. This post shows the steps I took to get Remix Routing working with Angular.


TL;DR

You can view the demo app here: https://remix-router-angular.netlify.app/

GitHub Repo: https://github.com/brandonroberts/remix-router-angular


It all started with a tweet...

Kent C. Dodds Remix Angular

Some time passes, and we get more breadcrumbs.

Ryan Florence

So what does this mean? I went digging and sure enough, @remix-run/router exists as a standalone package to handle the underlying logic for managing router state, browser history, and more.

Building the Router Service

import { createBrowserRouter } from '@remix-run/router';

  const router = createBrowserRouter({
    routes,
  });

  router.initialize();
Enter fullscreen mode Exit fullscreen mode

A few lines and we have enough to integrate a routing solution in Angular? 🤔 Not so fast. Remix Router does handle setting up routing, providing a router state, imperative navigation, and more but does not handle rendering components. With Angular v14, rendering dynamic components has become much easier, so at most we need a router service, and an outlet component to get going.

export const ROUTES = new InjectionToken<RouteObject[]>('ROUTES');

export const REMIX_ROUTER = new InjectionToken('Remix Router', {
  providedIn: 'root',
  factory() {
    const routes = inject(ROUTES);
    const router = createBrowserRouter({
      routes,
    });
    router.initialize();
    return router;
  },
});
Enter fullscreen mode Exit fullscreen mode

The Remix Router does need all the routes to be defined upfront. The code above defines some injection tokens so we can inject provided routes, and create the browser router for the router service.

@Injectable({
  providedIn: 'root',
})
export class Router {
  private _remixRouter = inject(REMIX_ROUTER);
  routerState$ = new BehaviorSubject<RouterState>(this._remixRouter.state);

  constructor() {
    this._remixRouter.subscribe((rs) => this.routerState$.next(rs));
  }

  get state() {
    return this._remixRouter.state;
  }

  navigate(path: string, opts?: NavigateOptions) {
    this._remixRouter.navigate(path, opts);
  }
}

export function provideRoutes(routes: RouteObject[]) {
  return [{ provide: ROUTES, useValue: routes }];
}
Enter fullscreen mode Exit fullscreen mode

The router service is pretty thin. No PlatformLocation or Location services from Angular, as that's handled by remix router. The remix router has a subscribe method to listen to when the router state changes, so we wrap that in a nice observable for everyone to listen to.

Next up is the outlet for rendering components.

Building a Router Outlet

@Directive({
  selector: 'outlet',
  standalone: true,
})
export class Outlet {
  private destroy$ = new Subject();
  private cmp!: Type<any>;
  private context? = getRouteContext();
  private router = getRouter();
  private vcr = inject(ViewContainerRef);

  ngOnInit() {
    this.setUpListener();
  }

  setUpListener() {
    this.router.routerState$
      .pipe(
        tap((rs) => {
          const matchesToRender = this.getMatch(rs);
          const currentCmp = matchesToRender.route.element;

          if (this.cmp !== currentCmp) {
            this.vcr.clear();
            this.vcr.createComponent(currentCmp, {
              injector: this.getInjector(matchesToRender),
            });
            this.cmp = currentCmp;
          }
        }),
        takeUntil(this.destroy$)
      )
      .subscribe();
  }

  getInjector(matchesToRender: DataRouteMatch) {
    const injector = Injector.create({
      providers: [
        {
          provide: ROUTE_CONTEXT,
          useValue: {
            id: matchesToRender.route.id,
            index: matchesToRender.route.index === true,
            params: matchesToRender.params,
          },
        },
      ],
      parent: this.vcr.injector,
    });

    return injector;
  }

  getMatch(routerState: RouterState) {
    const { matches } = routerState;
    const idx = matches.findIndex(
      (match) => match.route.id === this.context?.id
    );
    const matchesToRender = matches[idx + 1];

    return matchesToRender;
  }

  ngOnDestroy() {
    this.destroy$.next(true);
  }
}
Enter fullscreen mode Exit fullscreen mode

The outlet is a placeholder directive that listens to the router state changes, and renders the component that matches the route by id. Remix Router knows all the paths, so it provides an array of matches to render. This allows us to handle nested routing without too much effort.

A parent component can be defined with the outlet directive to render child routes

@Component({
  selector: 'home',
  standalone: true,
  imports: [Outlet],
  template: `
    Parent - 
    <a (click)="child('child')">Child</a>

    <outlet></outlet>
  `
})
export class ParentComponent {
  router = getRouter();
}
Enter fullscreen mode Exit fullscreen mode

Defining Routes

Now that I have a router and an outlet, I can register some routes.

import { RouteObject } from 'remix-router-angular';

import {
  AboutComponent,
  loader as aboutLoader,
  action as aboutAction,
} from './about.component';
import { HomeComponent } from './home.component';
import { ParentComponent } from './parent.component';
import { ChildComponent } from './child.component';

export const routes: RouteObject[] = [
  { path: '/', element: HomeComponent },
  {
    path: '/parent',
    element: ParentComponent,
    children: [
      {
        path: ':child',
        element: ChildComponent,
      },
    ],
  },
  {
    path: '/about',
    element: AboutComponent,
    action: aboutAction,
    loader: aboutLoader,
  },
];
Enter fullscreen mode Exit fullscreen mode

The about route uses a loader for loading data, and an action for processing form data. These work just the same as in Remix today.

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const name = formData.get('name');

  if (!name) {
    return {
      name: 'Name is required',
    };
  }

  return redirect(`/?name=${name}`);
};

export const loader: LoaderFunction = async () => {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
  const todos = await res.json();

  return json({ todos });
};
Enter fullscreen mode Exit fullscreen mode

Actions and loaders allow you prefetch data for the route, handle form validation, redirects, and more.

Providing Routes

Because I'm using Angular v14 with standalone features, I used the bootstrapApplication function, and pass some providers through the provideRoutes function.

import { enableProdMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRoutes } from 'remix-router-angular';

import { AppComponent } from './app/app.component';
import { routes } from './app/routes';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
  providers: [provideRoutes(routes)],
});
Enter fullscreen mode Exit fullscreen mode

I added some polish for using the new inject() function to provide access to get action/loader data from the Remix Router, and other pieces, but for early stage developments, everything works reasonably well.

I'm exciting to see this develop more! Kudos to the Remix team for putting in the hard work on this.

You can view the demo app here: https://remix-router-angular.netlify.app/

GitHub Repo: https://github.com/brandonroberts/remix-router-angular

Learn more

Remix
Angular
Angular v14 Release Post
remix-router-vue

If you liked this, click the ❤️ so other people will see it. Follow me on Twitter and subscribe to my YouTube Channel for more content on Angular, NgRx, and more!

Discussion (1)

Collapse
scooperdev profile image
Stephen Cooper

Loving these new agnostic possibilities! Thanks for sharing.