Skip to main content
Angular

Standalone Angular Without the Rewrite

Standalone components removed a layer of ceremony from Angular. They also removed the structure that was holding some codebases together. Here is what to keep.

Farhan Tanvir8 min read
Abstract diagram of standalone Angular component dependencies and lazy route boundaries

Standalone components let you delete a great deal of NgModule boilerplate. That is a real improvement. The risk is what the boilerplate was accidentally doing: forcing you to declare, in one file per feature, exactly what that feature depended on.

Remove the modules without replacing that discipline and you get a codebase where every component imports whatever it needs directly, and nothing tells you the feature boundaries have dissolved.

Imports are now the boundary

With standalone components, the imports array is your dependency declaration. It is worth treating it as one.

@Component({
  selector: 'ft-project-card',
  standalone: true,
  imports: [RouterLink, NgOptimizedImage],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="group rounded-2xl border border-slate-200 bg-white p-6">
      <a [routerLink]="['/portfolio', project().slug]" class="focus-ring">
        <h3 class="text-lg font-semibold">{{ project().title }}</h3>
      </a>
      <p class="mt-2 text-slate-600">{{ project().summary }}</p>
    </article>
  `,
})
export class ProjectCardComponent {
  project = input.required<ProjectSummary>();
}

A presentational component that imports a data service is a design smell you can now see in the decorator. Previously it was hidden in a module three directories away.

Lazy loading gets simpler, so use it more

loadComponent makes route-level code splitting cheap enough that there is no reason to ship the admin bundle to public visitors.

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./pages/home/home.component')
      .then(m => m.HomeComponent),
  },
  {
    path: 'blog/:slug',
    loadComponent: () => import('./pages/blog/blog-detail.component')
      .then(m => m.BlogDetailComponent),
  },
  {
    path: 'admin',
    canMatch: [adminGuard],
    loadChildren: () => import('./admin/admin.routes')
      .then(m => m.ADMIN_ROUTES),
  },
];

Note canMatch rather than canActivate on the admin branch. canActivate runs after the chunk is fetched; canMatch runs before, so an unauthenticated visitor never downloads the admin code at all. It is a small difference with a real effect on the initial bundle a first-time visitor pays for.

Functional guards are testable in a way class guards were not

export const adminGuard: CanMatchFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) {
    return true;
  }
  return router.createUrlTree(['/admin/login']);
};

The test needs no TestBed module setup, no provider overrides, no fixture:

it('redirects unauthenticated users to login', () => {
  TestBed.configureTestingModule({
    providers: [{ provide: AuthService, useValue: { isAuthenticated: () => false } }],
  });

  const result = TestBed.runInInjectionContext(() => adminGuard({} as Route, []));
  expect(result).toEqual(jasmine.any(UrlTree));
});

That is the whole test. When guards are this cheap to verify, they actually get verified — which matters, because a guard is a security control and an untested one is a guess.

Signals for state, RxJS for events

The pragmatic split that has held up for me: signals hold state that the template renders; RxJS handles asynchronous streams and events.

@Component({ /* ... */ })
export class BlogListComponent {
  private readonly blogService = inject(BlogService);

  readonly page = signal(0);
  readonly query = signal('');
  readonly loading = signal(false);
  readonly result = signal<PageResponse<PostSummary> | null>(null);

  readonly hasResults = computed(() => (this.result()?.content.length ?? 0) > 0);

  search(term: string): void {
    this.query.set(term);
    this.page.set(0);
    this.load();
  }
}

hasResults is derived, so it cannot disagree with result. That class of bug — two pieces of state that are supposed to agree and eventually do not — is what computed exists to remove.

Where you still want RxJS is anything with time in it. Debounced search is the obvious case, and it remains clearer as a stream than as an effect with a manual timer.

What not to do

Do not convert everything at once. Standalone components interoperate with NgModules in both directions, so a module can import a standalone component and a standalone component can import a module's exports. Migrate at the feature boundary, leave working code alone, and let the two coexist for as long as it takes.

A migration that produces no user-visible improvement is not worth a regression.