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...
Some time passes, and we get more breadcrumbs.
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();
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;
},
});
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 }];
}
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);
}
}
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();
}
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,
},
];
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 });
};
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)],
});
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!
Top comments (1)
Loving these new agnostic possibilities! Thanks for sharing.